realtimex-crm 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/realtimex-crm.js +56 -32
  2. package/dist/assets/{DealList-DnGVfS15.js → DealList-DbwJCRGl.js} +2 -2
  3. package/dist/assets/{DealList-DnGVfS15.js.map → DealList-DbwJCRGl.js.map} +1 -1
  4. package/dist/assets/index-C__S90Gb.css +1 -0
  5. package/dist/assets/index-mE-upBfc.js +166 -0
  6. package/dist/assets/{index-DPrpo5Xq.js.map → index-mE-upBfc.js.map} +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/stats.html +1 -1
  9. package/package.json +2 -1
  10. package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
  11. package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
  12. package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
  13. package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
  14. package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
  15. package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
  16. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +15 -3
  17. package/supabase/fix_webhook_hardcoded.sql +34 -0
  18. package/supabase/functions/_shared/ingestionGuard.ts +128 -0
  19. package/supabase/functions/_shared/utils.ts +1 -1
  20. package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
  21. package/supabase/functions/ingest-activity/index.ts +261 -0
  22. package/supabase/migrations/20251219120100_webhook_triggers.sql +10 -5
  23. package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
  24. package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
  25. package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
  26. package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
  27. package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
  28. package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
  29. package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
  30. package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
  31. package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
  32. package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
  33. package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
  34. package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
  35. package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
  36. package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
  37. package/dist/assets/index-DPrpo5Xq.js +0 -159
  38. package/dist/assets/index-kM1Og1AS.css +0 -1
@@ -0,0 +1,139 @@
1
+ import { useState } from "react";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useDataProvider, useNotify } from "ra-core";
4
+ import { Button } from "@/components/ui/button";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogFooter,
12
+ } from "@/components/ui/dialog";
13
+ import { Input } from "@/components/ui/input";
14
+ import { Label } from "@/components/ui/label";
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from "@/components/ui/select";
22
+ import { Loader2 } from "lucide-react";
23
+
24
+ interface CreateChannelDialogProps {
25
+ open: boolean;
26
+ onClose: () => void;
27
+ }
28
+
29
+ export const CreateChannelDialog = ({
30
+ open,
31
+ onClose,
32
+ }: CreateChannelDialogProps) => {
33
+ const [providerCode, setProviderCode] = useState<string>("twilio");
34
+ const [name, setName] = useState("");
35
+ const [authToken, setAuthToken] = useState("");
36
+ const dataProvider = useDataProvider();
37
+ const notify = useNotify();
38
+ const queryClient = useQueryClient();
39
+
40
+ const createMutation = useMutation({
41
+ mutationFn: async () => {
42
+ // Generate a cryptographically secure random ingestion key
43
+ const ingestionKey = "ik_live_" + crypto.randomUUID().replace(/-/g, '');
44
+
45
+ const config: any = {};
46
+ if (authToken) {
47
+ config.auth_token = authToken;
48
+ }
49
+
50
+ await dataProvider.create("ingestion_providers", {
51
+ data: {
52
+ name,
53
+ provider_code: providerCode,
54
+ is_active: true,
55
+ config,
56
+ ingestion_key: ingestionKey
57
+ },
58
+ });
59
+ },
60
+ onSuccess: () => {
61
+ queryClient.invalidateQueries({ queryKey: ["ingestion_providers"] });
62
+ notify("Ingestion Channel created successfully");
63
+ onClose();
64
+ // Reset form
65
+ setName("");
66
+ setAuthToken("");
67
+ setProviderCode("twilio");
68
+ },
69
+ onError: (error: Error) => {
70
+ notify(`Failed to create channel: ${error.message}`, { type: "error" });
71
+ },
72
+ });
73
+
74
+ return (
75
+ <Dialog open={open} onOpenChange={onClose}>
76
+ <DialogContent className="sm:max-w-[425px]">
77
+ <DialogHeader>
78
+ <DialogTitle>Add Ingestion Channel</DialogTitle>
79
+ <DialogDescription>
80
+ Configure a new source for incoming activities.
81
+ </DialogDescription>
82
+ </DialogHeader>
83
+ <div className="grid gap-4 py-4">
84
+ <div className="grid gap-2">
85
+ <Label htmlFor="name">Channel Name</Label>
86
+ <Input
87
+ id="name"
88
+ placeholder="e.g. US Support Line"
89
+ value={name}
90
+ onChange={(e) => setName(e.target.value)}
91
+ />
92
+ </div>
93
+ <div className="grid gap-2">
94
+ <Label htmlFor="provider">Provider</Label>
95
+ <Select value={providerCode} onValueChange={setProviderCode}>
96
+ <SelectTrigger>
97
+ <SelectValue placeholder="Select provider" />
98
+ </SelectTrigger>
99
+ <SelectContent>
100
+ <SelectItem value="twilio">Twilio (Voice/SMS)</SelectItem>
101
+ <SelectItem value="postmark">Postmark (Email)</SelectItem>
102
+ <SelectItem value="generic">Generic / Internal</SelectItem>
103
+ </SelectContent>
104
+ </Select>
105
+ </div>
106
+
107
+ {providerCode === "twilio" && (
108
+ <div className="grid gap-2">
109
+ <Label htmlFor="token">Auth Token (Validation)</Label>
110
+ <Input
111
+ id="token"
112
+ type="password"
113
+ placeholder="Twilio Auth Token"
114
+ value={authToken}
115
+ onChange={(e) => setAuthToken(e.target.value)}
116
+ />
117
+ <p className="text-xs text-muted-foreground">Required to validate inbound requests.</p>
118
+ </div>
119
+ )}
120
+
121
+ </div>
122
+ <DialogFooter>
123
+ <Button variant="outline" onClick={onClose}>
124
+ Cancel
125
+ </Button>
126
+ <Button
127
+ onClick={() => createMutation.mutate()}
128
+ disabled={!name || createMutation.isPending}
129
+ >
130
+ {createMutation.isPending && (
131
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
132
+ )}
133
+ Create Channel
134
+ </Button>
135
+ </DialogFooter>
136
+ </DialogContent>
137
+ </Dialog>
138
+ );
139
+ };
@@ -0,0 +1,188 @@
1
+ import { useState } from "react";
2
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useDataProvider, useNotify } from "ra-core";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Label } from "@/components/ui/label";
7
+ import { Plus, Trash2, Copy, Activity } from "lucide-react";
8
+ import { CreateChannelDialog } from "./CreateChannelDialog";
9
+ import { Badge } from "@/components/ui/badge";
10
+ import { format } from "date-fns";
11
+ import { getSupabaseConfig } from "@/lib/supabase-config";
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ } from "@/components/ui/alert-dialog";
22
+
23
+ export const IngestionChannelsTab = () => {
24
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
25
+ const [channelToDelete, setChannelToDelete] = useState<string | null>(null);
26
+ const dataProvider = useDataProvider();
27
+ const notify = useNotify();
28
+ const queryClient = useQueryClient();
29
+
30
+ const { data: channels, isLoading } = useQuery({
31
+ queryKey: ["ingestion_providers"],
32
+ queryFn: async () => {
33
+ const { data } = await dataProvider.getList("ingestion_providers", {
34
+ pagination: { page: 1, perPage: 100 },
35
+ sort: { field: "created_at", order: "DESC" },
36
+ filter: {},
37
+ });
38
+ return data;
39
+ },
40
+ });
41
+
42
+ const deleteMutation = useMutation({
43
+ mutationFn: async (id: string) => {
44
+ await dataProvider.delete("ingestion_providers", { id, previousData: {} });
45
+ },
46
+ onSuccess: () => {
47
+ queryClient.invalidateQueries({ queryKey: ["ingestion_providers"] });
48
+ notify("Channel deleted successfully");
49
+ setChannelToDelete(null);
50
+ },
51
+ onError: () => {
52
+ notify("Failed to delete channel", { type: "error" });
53
+ },
54
+ });
55
+
56
+ return (
57
+ <div className="space-y-4">
58
+ <div className="flex justify-between items-center">
59
+ <p className="text-sm text-muted-foreground">
60
+ Configure inbound channels (Email, Voice, SMS) to ingest activities into your CRM.
61
+ </p>
62
+ <Button onClick={() => setShowCreateDialog(true)}>
63
+ <Plus className="h-4 w-4 mr-2" />
64
+ Add Channel
65
+ </Button>
66
+ </div>
67
+
68
+ {isLoading ? (
69
+ <Card>
70
+ <CardContent className="py-8 text-center text-muted-foreground">
71
+ Loading...
72
+ </CardContent>
73
+ </Card>
74
+ ) : channels && channels.length > 0 ? (
75
+ <div className="grid gap-4 md:grid-cols-2">
76
+ {channels.map((channel: any) => (
77
+ <ChannelCard
78
+ key={channel.id}
79
+ channel={channel}
80
+ onDelete={() => setChannelToDelete(channel.id)}
81
+ />
82
+ ))}
83
+ </div>
84
+ ) : (
85
+ <Card>
86
+ <CardContent className="py-12 text-center">
87
+ <p className="text-muted-foreground mb-4">No ingestion channels configured</p>
88
+ <Button onClick={() => setShowCreateDialog(true)}>
89
+ <Plus className="h-4 w-4 mr-2" />
90
+ Add your first channel
91
+ </Button>
92
+ </CardContent>
93
+ </Card>
94
+ )}
95
+
96
+ <CreateChannelDialog
97
+ open={showCreateDialog}
98
+ onClose={() => setShowCreateDialog(false)}
99
+ />
100
+
101
+ <AlertDialog
102
+ open={channelToDelete !== null}
103
+ onOpenChange={() => setChannelToDelete(null)}
104
+ >
105
+ <AlertDialogContent>
106
+ <AlertDialogHeader>
107
+ <AlertDialogTitle>Delete Channel?</AlertDialogTitle>
108
+ <AlertDialogDescription>
109
+ This will stop all ingestion from this source. This action cannot be undone.
110
+ </AlertDialogDescription>
111
+ </AlertDialogHeader>
112
+ <AlertDialogFooter>
113
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
114
+ <AlertDialogAction
115
+ onClick={() => channelToDelete && deleteMutation.mutate(channelToDelete)}
116
+ className="bg-destructive hover:bg-destructive/90"
117
+ >
118
+ Delete
119
+ </AlertDialogAction>
120
+ </AlertDialogFooter>
121
+ </AlertDialogContent>
122
+ </AlertDialog>
123
+ </div>
124
+ );
125
+ };
126
+
127
+ const ChannelCard = ({
128
+ channel,
129
+ onDelete,
130
+ }: {
131
+ channel: any;
132
+ onDelete: () => void;
133
+ }) => {
134
+ const notify = useNotify();
135
+
136
+ // Get the actual Supabase URL from config (localStorage or env vars)
137
+ const supabaseConfig = getSupabaseConfig();
138
+ const webhookUrl = supabaseConfig
139
+ ? `${supabaseConfig.url}/functions/v1/ingest-activity?key=${channel.ingestion_key}`
140
+ : `https://your-project.supabase.co/functions/v1/ingest-activity?key=${channel.ingestion_key}`;
141
+
142
+ const copyUrl = () => {
143
+ navigator.clipboard.writeText(webhookUrl);
144
+ notify("Webhook URL copied to clipboard");
145
+ };
146
+
147
+ return (
148
+ <Card>
149
+ <CardHeader className="pb-3">
150
+ <div className="flex justify-between items-start">
151
+ <div className="flex items-center gap-3">
152
+ <div className="p-2 bg-primary/10 rounded-full">
153
+ <Activity className="h-5 w-5 text-primary" />
154
+ </div>
155
+ <div>
156
+ <CardTitle className="text-base">{channel.name}</CardTitle>
157
+ <div className="flex gap-2 mt-1">
158
+ <Badge variant="outline">{channel.provider_code}</Badge>
159
+ {channel.is_active ? (
160
+ <Badge className="bg-green-500 hover:bg-green-600">Active</Badge>
161
+ ) : (
162
+ <Badge variant="secondary">Inactive</Badge>
163
+ )}
164
+ </div>
165
+ </div>
166
+ </div>
167
+ <Button variant="ghost" size="icon" onClick={onDelete}>
168
+ <Trash2 className="h-4 w-4 text-destructive" />
169
+ </Button>
170
+ </div>
171
+ </CardHeader>
172
+ <CardContent className="space-y-3">
173
+ <div className="space-y-1">
174
+ <Label className="text-xs text-muted-foreground">Webhook URL</Label>
175
+ <div className="flex items-center gap-2 font-mono text-xs bg-muted p-2 rounded overflow-hidden">
176
+ <span className="truncate">{webhookUrl}</span>
177
+ <Button variant="ghost" size="icon" className="h-6 w-6 ml-auto flex-shrink-0" onClick={copyUrl}>
178
+ <Copy className="h-3 w-3" />
179
+ </Button>
180
+ </div>
181
+ </div>
182
+ <div className="text-xs text-muted-foreground">
183
+ <p>Created: {format(new Date(channel.created_at), "PPP")}</p>
184
+ </div>
185
+ </CardContent>
186
+ </Card>
187
+ );
188
+ };
@@ -1,6 +1,8 @@
1
1
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2
2
  import { ApiKeysTab } from "./ApiKeysTab";
3
3
  import { WebhooksTab } from "./WebhooksTab";
4
+ import { IngestionChannelsTab } from "./IngestionChannelsTab";
5
+ import { FileUpload } from "../activities/FileUpload";
4
6
 
5
7
  export const IntegrationsPage = () => {
6
8
  return (
@@ -8,17 +10,27 @@ export const IntegrationsPage = () => {
8
10
  <div className="mb-6">
9
11
  <h1 className="text-3xl font-bold">Integrations</h1>
10
12
  <p className="text-muted-foreground mt-2">
11
- Manage API keys and webhooks to integrate Atomic CRM with external
13
+ Manage API keys, webhooks, and ingestion channels to integrate Atomic CRM with external
12
14
  systems.
13
15
  </p>
14
16
  </div>
15
17
 
16
- <Tabs defaultValue="api-keys">
18
+ <Tabs defaultValue="ingestion">
17
19
  <TabsList className="mb-4">
20
+ <TabsTrigger value="ingestion">Ingestion Channels</TabsTrigger>
21
+ <TabsTrigger value="file-upload">File Upload</TabsTrigger>
18
22
  <TabsTrigger value="api-keys">API Keys</TabsTrigger>
19
- <TabsTrigger value="webhooks">Webhooks</TabsTrigger>
23
+ <TabsTrigger value="webhooks">Webhooks (Outbound)</TabsTrigger>
20
24
  </TabsList>
21
25
 
26
+ <TabsContent value="ingestion">
27
+ <IngestionChannelsTab />
28
+ </TabsContent>
29
+
30
+ <TabsContent value="file-upload">
31
+ <FileUpload />
32
+ </TabsContent>
33
+
22
34
  <TabsContent value="api-keys">
23
35
  <ApiKeysTab />
24
36
  </TabsContent>
@@ -0,0 +1,34 @@
1
+ -- ==================================================================================
2
+ -- HARDCODED WEBHOOK FIX (Bypasses Permission Issues)
3
+ -- ==================================================================================
4
+
5
+ -- 1. Ensure extensions are active
6
+ CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
7
+ CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
8
+
9
+ -- 2. Remove the old dynamic job that relies on missing settings
10
+ SELECT cron.unschedule(jobid)
11
+ FROM cron.job
12
+ WHERE jobname = 'webhook-dispatcher';
13
+
14
+ -- 3. Schedule the new job with HARDCODED credentials
15
+ -- !!! IMPORTANT: Replace [YOUR_SERVICE_ROLE_KEY] below before running !!!
16
+
17
+ SELECT cron.schedule(
18
+ 'webhook-dispatcher',
19
+ '* * * * *', -- Run every minute
20
+ $$
21
+ select
22
+ net.http_post(
23
+ url:='https://xydvyhnspkzcsocewhuy.supabase.co/functions/v1/webhook-dispatcher',
24
+ headers:=jsonb_build_object(
25
+ 'Content-Type','application/json',
26
+ 'Authorization', 'Bearer [YOUR_SERVICE_ROLE_KEY]'
27
+ ),
28
+ body:='{}'::jsonb
29
+ ) as request_id;
30
+ $$
31
+ );
32
+
33
+ -- 4. Check that the job is scheduled
34
+ SELECT jobid, jobname, command FROM cron.job WHERE jobname = 'webhook-dispatcher';
@@ -0,0 +1,128 @@
1
+ import { createErrorResponse } from "./utils.ts";
2
+ import { supabaseAdmin } from "./supabaseAdmin.ts";
3
+
4
+ /**
5
+ * Validates the request signature or API key.
6
+ * Returns the resolved provider config or an error response.
7
+ * Note: For Twilio, signature validation must be done separately after body parsing
8
+ * using validateTwilioWebhook()
9
+ */
10
+ export async function validateIngestionRequest(req: Request) {
11
+ const url = new URL(req.url);
12
+ const providerCode = url.searchParams.get("provider");
13
+
14
+ // Check for ingestion key in header (preferred) or URL query param (backward compatibility)
15
+ const ingestionKey = req.headers.get("x-ingestion-key") || url.searchParams.get("key");
16
+
17
+ // 1. Internal/Manual API Key (Bearer)
18
+ const authHeader = req.headers.get("Authorization");
19
+ if (authHeader?.startsWith("Bearer ak_live_")) {
20
+ // Validate Internal API Key (already implemented in apiKeyAuth.ts)
21
+ // For V1, we trust internal keys as "generic" provider
22
+ return { provider: { id: null, provider_code: "generic", sales_id: null } };
23
+ }
24
+
25
+ // 2. Identify Provider by 'ingestion_key' (Preferred)
26
+ if (ingestionKey) {
27
+ const { data: provider, error } = await supabaseAdmin
28
+ .from("ingestion_providers")
29
+ .select("*")
30
+ .eq("ingestion_key", ingestionKey)
31
+ .eq("is_active", true)
32
+ .single();
33
+
34
+ if (error || !provider) {
35
+ return { error: createErrorResponse(401, "Invalid Ingestion Key") };
36
+ }
37
+
38
+ return { provider };
39
+ }
40
+
41
+ // 3. Fallback: Identify by Query Param (Legacy/Public Webhooks)
42
+ if (providerCode === "twilio") {
43
+ // In this case, we need to find WHICH Twilio config to use.
44
+ // Usually, we match by the 'To' phone number in the body, but that requires parsing the body first.
45
+ // For strict security, we REJECT requests without an ingestion_key in the URL.
46
+ return { error: createErrorResponse(401, "Missing 'key' parameter in webhook URL") };
47
+ }
48
+
49
+ return { error: createErrorResponse(400, "Unknown Provider or Missing Authentication") };
50
+ }
51
+
52
+ /**
53
+ * Validates a Twilio webhook request after body parsing
54
+ * Call this after validateIngestionRequest() and body parsing
55
+ */
56
+ export async function validateTwilioWebhook(
57
+ req: Request,
58
+ authToken: string,
59
+ body: Record<string, any>
60
+ ): Promise<boolean> {
61
+ return await validateTwilioSignature(req, authToken, body);
62
+ }
63
+
64
+ /**
65
+ * Validates Twilio X-Twilio-Signature using HMAC-SHA1
66
+ * Implementation follows Twilio's security specification:
67
+ * https://www.twilio.com/docs/usage/security#validating-requests
68
+ */
69
+ async function validateTwilioSignature(
70
+ req: Request,
71
+ authToken: string,
72
+ body: Record<string, any>
73
+ ): Promise<boolean> {
74
+ const signature = req.headers.get("X-Twilio-Signature");
75
+ if (!signature) return false;
76
+
77
+ try {
78
+ // 1. Get the full URL (including protocol, host, path, and query params)
79
+ const url = req.url;
80
+
81
+ // 2. Sort POST parameters alphabetically and concatenate
82
+ const sortedParams = Object.keys(body)
83
+ .sort()
84
+ .map((key) => `${key}${body[key]}`)
85
+ .join("");
86
+
87
+ // 3. Concatenate URL + sorted params
88
+ const data = url + sortedParams;
89
+
90
+ // 4. Compute HMAC-SHA1
91
+ const encoder = new TextEncoder();
92
+ const keyData = encoder.encode(authToken);
93
+ const messageData = encoder.encode(data);
94
+
95
+ const cryptoKey = await crypto.subtle.importKey(
96
+ "raw",
97
+ keyData,
98
+ { name: "HMAC", hash: "SHA-1" },
99
+ false,
100
+ ["sign"]
101
+ );
102
+
103
+ const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
104
+
105
+ // 5. Convert to Base64
106
+ const signatureArray = Array.from(new Uint8Array(signatureBuffer));
107
+ const signatureBase64 = btoa(String.fromCharCode(...signatureArray));
108
+
109
+ // 6. Constant-time comparison to prevent timing attacks
110
+ return constantTimeCompare(signature, signatureBase64);
111
+ } catch (error) {
112
+ console.error("Twilio signature validation error:", error);
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Constant-time string comparison to prevent timing attacks
119
+ */
120
+ function constantTimeCompare(a: string, b: string): boolean {
121
+ if (a.length !== b.length) return false;
122
+
123
+ let result = 0;
124
+ for (let i = 0; i < a.length; i++) {
125
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
126
+ }
127
+ return result === 0;
128
+ }
@@ -1,7 +1,7 @@
1
1
  export const corsHeaders = {
2
2
  "Access-Control-Allow-Origin": "*",
3
3
  "Access-Control-Allow-Headers":
4
- "authorization, x-client-info, apikey, content-type",
4
+ "authorization, x-client-info, apikey, content-type, x-ingestion-key",
5
5
  "Access-Control-Allow-Methods": "POST, PATCH, PUT, DELETE",
6
6
  };
7
7
 
@@ -0,0 +1,4 @@
1
+ # Disable JWT verification for this function
2
+ # Webhooks from Twilio, Postmark, etc. don't have Supabase JWTs
3
+ # Security is handled via ingestion_key parameter and provider-specific signatures
4
+ verify_jwt = false