Lab · Tutorial
A sponsorship desk in your DMs
Manychat to Supabase, built to survive a leaked key.
Brand deals usually start in the DMs. A brand manager messages a creator with a large Instagram audience, the message lands next to hundreds of fan replies, and someone on the team copies the useful bits into a spreadsheet by hand, if they spot it at all. Most "work with me" pages still say the same thing: send us a DM.
We built a small open-source desk that does the copying for you. A Manychat flow asks a brand five short questions, asks for consent, and sends the answers to a Supabase table, where each one waits as a lead for a person to review. The interesting part is the security. Manychat can't sign the request it sends, so anyone who learns the shared key can call the endpoint. We built the whole thing on the assumption that the key will leak one day, and made a stolen key nearly worthless.
Nothing from YouTube loads on this page.
1. What we didn't find
We went looking for an existing setup before writing one. What follows is what our searches on October 31, 2026 turned up, not a claim about everything that exists.
- People do connect Manychat to Supabase, in private builds. On X, a search for "manychat supabase" (Latest tab) returned six posts. Three describe real builds that connect the two:
- an "AI employee" that reads TikTok DMs and scores leads, built on Manychat, a Cloudflare Worker, Openclaw and Supabase (@alienmilian, March 2, 2026)
- an AI hair try-on, where Manychat collects the input, an n8n webhook receives it and Supabase stores it (@RexVirtus, October 26, 2025, and the same build a day later)
- No published connector. A GitHub repository search for "manychat supabase" returned no repositories. Code search does find Manychat handlers tucked inside individual apps' Supabase folders, but no standalone, documented setup you could pick up and use. The nearest open project we saw on X goes the other way: OpenReply, built to "replace ManyChat comment automation" (@SahilPanhotra, July 30, 2026).
- Nothing on securing Manychat's requests. X searches pairing Manychat with webhook, external request or API, and with security, secret, signature or verify, found nothing on the subject.
- No sponsorship desk. X searches pairing Manychat with sponsorship, brand deals, brand partnership or brand collab found no one taking sponsorship inquiries through it. The closest was a creator who uses Manychat only for comment giveaways and screens brand deals by hand: "Screening offers, negotiating, making sure contracts are good, etc. is taking more time than I thought" (@dibratto, September 29, 2026).
So in our search, people connect Manychat to Supabase, but nobody publishes the code or deals with the unsigned request, and nobody uses it as a sponsorship desk. That is the gap this tutorial fills.
2. How data leaves Manychat
Manychat has no account-wide webhook that fires on every event. Data leaves only when a step inside a flow sends it. For a custom endpoint, that step is External Request. Manychat's help center sets out its rules:
- "Only HTTPS links are allowed", with headers and a JSON body (Dev Tools: External request).
- "Timeout is limited to 10 seconds. It cannot be changed." Response values can only be mapped back into Manychat when the reply is "200 OK" (Dev Tools: Basics).
- If a request fails and the step has no fallback, "the rest of the automation will not be processed" (same page).
- Neither article mentions request signing, published source IP ranges or retries. When a user asked for a fixed IP to allow-list, a Manychat moderator suggested "an API key or some other form of authentication" instead (Manychat Community).
- External Request is "available on certain paid plans". The Pro plan page is the first to list API access, so we treat Pro as the floor. Check your own plan before you build.
So the only proof that a request came from your flow is a static string in a header. That string can be seen by anyone with edit access to the flow, and Manychat gives you no way to check that a request wasn't copied or replayed.
3. Assume the key leaks
If the caller can't be proven, the endpoint has to be harmless. The desk is a dedicated Supabase Edge Function, separate from anything else in the project. Supabase checks a JWT on every function by default, and Manychat can't send one, so this one function turns that check off in supabase/config.toml and authenticates in code (Supabase: auth headers):
[functions.manychat-intake]
verify_jwt = false
The key arrives in an x-intake-key header and is compared in constant time. Both sides are hashed first, because "a length mismatch alone leaks timing information" (Deno docs). There are two key slots, so you can rotate without downtime:
import { timingSafeEqual } from "jsr:@std/crypto@1/timing-safe-equal";
const [p, c, n] = await Promise.all([
sha256(presented ?? ""),
sha256(current ?? UNMATCHABLE),
sha256(next ?? UNMATCHABLE),
]);
const okCurrent = timingSafeEqual(p, c) && current !== null && presented !== null;
const okNext = timingSafeEqual(p, n) && next !== null && presented !== null;
return okCurrent ? "current" : okNext ? "next" : null;
After the key check comes a strict schema. Unknown fields are rejected, every string has a length cap, and consent is required. The body is capped at 8 KB while it is being read, so the function never trusts Content-Length. An idempotency key, the SHA-256 of the Manychat contact ID and the flow ID, makes a re-run of the same flow by the same person collapse into the first row. Rate limits apply per contact and per function instance. And INTAKE_ENABLED is a kill switch: anything other than the exact string true closes the desk.
4. The database does the real work
The function never uses a Supabase secret key. Those keys bypass Row Level Security (Supabase: secrets). Instead, it logs in as its own role, manychat_intake, which can insert into one table and do nothing else. It can't even choose a row's status:
grant insert (
idempotency_key, creator_id, manychat_contact_id, flow_id, ig_username,
role, brand_name, website, contact_name, contact_role, work_email,
free_mail, email_matches_site, consent, consent_text_version, live_chat_url
) on public.brand_leads to manychat_intake;
create policy intake_inserts_needs_review_only
on public.brand_leads for insert to manychat_intake
with check (status = 'needs_review' and consent = true and reviewed_at is null);
Here is that role on our local database, trying everything a thief might want:
INSERT 0 1 -- a new lead: stored
INSERT 0 0 -- the same lead again: ignored
ERROR: permission denied for table brand_leads -- insert with status 'qualified'
ERROR: new row violates row-level security policy -- insert with consent = false
ERROR: permission denied for table brand_leads -- select
ERROR: permission denied for table brand_leads -- update
ERROR: permission denied for table brand_leads -- delete
psql as manychat_intake against the Supabase CLI local stack, annotated
So the worst a stolen key can do is drop a rate-limited lead into a review queue that a person reads anyway.
5. The same reply for everyone
Every request the desk could take gets the same answer, 200 {"ok":true,"status":"received"}. That holds whether the lead was stored, was a duplicate, carried the wrong key or failed validation. Someone probing the URL learns nothing about your keys or your leads. The real outcome goes to the function log, which records the outcome, the key slot, the names of any failing fields, a 12-character reference and a time. It never records anything the person typed.
There is one deliberate exception. When the desk can't take leads at all (kill switch, overload, or a database that is down or slower than 1.5 seconds), it answers 503. Manychat then runs the flow's fallback branch, which tags the contact for a human. Slow work, such as pinging a reviewer, runs after the reply through EdgeRuntime.waitUntil (Supabase: background tasks). In our tests a reply takes milliseconds, well inside Manychat's 10 seconds.
6. The flow, and Meta's rules
Meta sets the shape of the conversation, not Manychat:
- Inbound first. A business can message someone only after that person has messaged it (Meta: Instagram messaging). No cold DMs.
- One private reply per comment, sent within 7 days of it (Meta: private replies). That reply doesn't open the 24-hour window. A tap or a reply does (Manychat).
- 24 hours, then humans only. After the window closes, a person can still reply by hand for up to 7 days. Automations can't (Manychat: messaging windows). Manychat notes that Meta deprecated Messenger message tags on February 9, 2026 (Manychat).
- 750 private replies an hour per account (Meta: rate limits). A viral post can't reply to everyone, so give brands their own way in, such as a DM keyword or a bio link.
That's why the flow collects a work email: it is how the conversation continues after day one. The flow runs in seven steps:
- The bot says it is a bot.
- It asks one question with two buttons: brand or agency, or fan? The tap opens the window. Fans get a friendly reply and nothing is stored.
- Brands are asked for the brand name, website, contact name, role and work email.
- A consent line links to the privacy policy. "Yes, send it" or "No thanks".
- The External Request sends the answers to Supabase.
- A holding reply: the team replies by email.
- If the send fails, the fallback adds an
intake_failedtag and sends the same holding reply, so no lead is stranded mid-conversation.
If you'd rather the answers never pass through Manychat at all, the bot can instead send a link to a one-page form you host. Keep the one-tap question first, though: Manychat says an "Open website" button doesn't open the 24-hour window.
7. Try it
The code repository is private for now; ask us for access. Everything runs locally with the Supabase CLI and Docker. No cloud project and no money needed:
git clone https://github.com/lrgfrmt/manychat-brand-intake.git
cd manychat-brand-intake
deno task test # 14 unit tests
supabase start # local Postgres + Edge Functions, applies the migration
cp supabase/functions/.env.example supabase/functions/.env
supabase functions serve manychat-intake --env-file supabase/functions/.env > intake.log 2>&1 &
LOG_FILE=intake.log scripts/test-intake.sh
1. accepted reply: {"ok":true,"status":"received"} HTTP 200 in 0.086940s
logged outcome: accepted (HTTP 200) | rows for this run: 1
2. rejected (bad key) reply: {"ok":true,"status":"received"} HTTP 200 in 0.006161s
logged outcome: bad_key (HTTP 200) | rows for this run: 1
3. duplicate reply: {"ok":true,"status":"received"} HTTP 200 in 0.011191s
logged outcome: duplicate (HTTP 200) | rows for this run: 1
4. missing consent reply: {"ok":true,"status":"received"} HTTP 200 in 0.003174s
logged outcome: invalid_fields (HTTP 200) | rows for this run: 1
scripts/test-intake.sh against the local function, invented data
Four identical replies, four different outcomes, one row. The repo's docs/manychat-setup.md walks through the real deployment step by step, from the role's password to the Manychat fields, the body template and key rotation.
8. Honest limits
- A stolen key still adds fake leads. They are rate limited and marked for review, but a person has to decline them. Rotate the key when
acceptedspikes from contacts you don't know. - Rate limits are per instance. They live in memory. For a hard project-wide cap, back them with a Postgres or Redis counter.
- A broken template fails quietly. Because a rejection looks exactly like a success to Manychat, check the function log when you set the flow up.
- One row per contact per flow. To take fresh inquiries from returning brands, bump the flow ID.
- We couldn't test inside a live Manychat account for this write-up. Everything above was run against the local Supabase stack with invented data.
9. Links
- The code, MIT licensed: the repository is private for now. Ask us for access.
- Manychat: manychat.com
- Supabase Edge Functions: supabase.com/docs/guides/functions
- Manychat External Request: help.manychat.com
Want an intake desk like this built for your team? Start a project with the team at LARGE FORMAT.