Lovable gets you to a working app fast. Then posts need to go out — and it turns out that's two different wishes with two different answers.
You want to post. You're in the chat window, you shipped something, and you want it on LinkedIn without opening a third tab. That takes one URL and about two minutes.
Your app wants to post. The product you're building publishes on behalf of the people using it. That needs a key that lives server-side, and it's the rest of this page.
Both run through the same place — Outstand, one API in front of twelve social networks — so the accounts you connect for the first are the accounts your app uses for the second. Start with the two-minute one.
Part 1 — Post from the chat window
Outstand runs a remote MCP server. Lovable can connect any MCP server as a chat connector — its docs say custom MCP servers "are available on all plans" — so this is a paste-a-URL job.
Connect it
Open Connectors in Lovable, hit the + in the top right of the catalog, choose MCP server, and fill in the form:
Field | Value |
|---|---|
Server name | Outstand |
Connection | Direct connection (the default) |
Server URL | https://mcp.outstand.so/mcp |
Authentication | OAuth |
Click Add & authorize. Outstand opens in the browser, you sign in, you pick which organization the connector gets access to, and you land back in Lovable with the connection live.
OAuth is worth taking over the API-key option here even though both work. Your AI client never receives a long-lived key, the token is scoped to the organization you picked, and you can revoke it from the Outstand dashboard without rotating anything else. The Bearer-token alternative exists for headless clients that can't run a browser flow.
What you can do with it
The server exposes 28 tools across posts, social accounts, media, networks and usage. In practice you don't call them by name — you ask, and the agent picks:
"List my connected social accounts."
"Post 'Dark mode is live — took three days and most of that was the toggle animation' to LinkedIn."
"Schedule that for Tuesday 9am UTC instead."
"How did last week's posts do?"
That last one reaches get_post_analytics, so you can ask about reach and engagement without leaving the build.
Check it's actually up
It's a public server; you don't have to take our word for it. It's listed in the official MCP registry:
1curl "https://registry.modelcontextprotocol.io/v0/servers?search=outstand"1{
2 "servers": [
3 {
4 "server": {
5 "name": "so.outstand/outstand",
6 "title": "Outstand",
7 "remotes": [
8 { "type": "streamable-http", "url": "https://mcp.outstand.so/mcp" }
9 ]
10 }
11 }
12 ],
13 "metadata": { "count": 1 }
14}And the endpoint is auth-gated, which you can confirm without an account — an unauthenticated initialize gets a 401 and a pointer to the OAuth metadata:
1curl -i -X POST https://mcp.outstand.so/mcp \
2 -H 'Content-Type: application/json' \
3 -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'1HTTP/2 401
2www-authenticate: Bearer resource_metadata="https://mcp.outstand.so/.well-known/oauth-protected-resource/mcp"
3
4{"error":"Missing or invalid Authorization header"}The limit, before you build on it
A chat connector is for you, not for your users. Lovable's own docs put it plainly:
"Chat connections, including custom MCP servers, only give Lovable context while building."
And, more bluntly: "Like all chat connections, they are personal to you, and they are never part of your published app."
So if what you want is your users posting from your product, the connector won't carry it, and nothing you wire through it will survive deploy. That's Part 2.
Part 2 — Post from the app itself
What you're building
Four hops, and only the first two are yours to write:
1Lovable app (browser) -> Supabase edge function -> Outstand API -> LinkedIn, X, ...
2 your React UI your key lives here one endpointThe edge function exists for one reason: it's the first place in the chain where a secret is safe. Lovable's docs are explicit that secrets are stored in your Supabase project for edge functions to read, and never appear in your app's code or repository. There's a second reason too — most APIs don't send CORS headers for browser origins, because they expect server-to-server calls. The edge function solves both problems at once.
Before you start
- A Lovable project with Supabase connected. If you haven't connected it, ask Lovable to — it wires the client and gives you the panel where secrets and edge functions live.
- An Outstand account and an API key. Generate a key from the dashboard and copy it somewhere safe immediately: you won't be able to see it again after generating it.
- The accounts you want to post to, connected. Eight of the twelve networks run on Outstand's managed apps, LinkedIn among them, so that one connects in a couple of clicks.
- For X specifically: your own X app. Four networks are bring-your-own-credentials — X, Google Business Profile, Vimeo and Reddit — so you register your own app at developer.x.com, set the type to Web App, enable OAuth 2.0, and set the callback to
https://www.outstand.so/app/api/socials/x/callback. Check the access level before you wire anything up: a brand-new developer app is not enrolled onPOST /2/tweetsby default, so OAuth will connect happily and you'll find out at the first publish. The X configuration guide has the scope list and the rest of the portal steps.
Do the LinkedIn half first. It works end to end in five minutes and proves the rest of the plumbing before X's developer-portal detour.
Step 1 — Put the key somewhere the browser can't reach
In your Lovable project, open the Supabase panel and add a secret named OUTSTAND_API_KEY.
The naming rule matters more than it looks: no VITE_ prefix. Vite inlines every VITE_-prefixed variable into the JavaScript bundle it ships to the browser. A key called VITE_OUTSTAND_API_KEY is a key you have published. Without the prefix, the value stays server-side and only edge functions can read it.
Quick way to check you got this right, any time: build your app and search the output bundle for the first six characters of your key. If it's in there, it's public.
Step 2 — The edge function that publishes
Ask Lovable to create an edge function called publish-post, or write the file yourself at supabase/functions/publish-post/index.ts:
1// supabase/functions/publish-post/index.ts
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
3
4const OUTSTAND_API = 'https://api.outstand.so';
5
6const cors = {
7 'Access-Control-Allow-Origin': '*',
8 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
9};
10
11Deno.serve(async (req) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors });
13
14 try {
15 // 1. Authenticate the caller. Without this, your function is an open relay
16 // that anyone can use to post from your accounts.
17 const supabase = createClient(
18 Deno.env.get('SUPABASE_URL')!,
19 Deno.env.get('SUPABASE_ANON_KEY')!,
20 { global: { headers: { Authorization: req.headers.get('Authorization')! } } },
21 );
22
23 const { data: { user } } = await supabase.auth.getUser();
24 if (!user) {
25 return new Response(JSON.stringify({ error: 'Not signed in' }), {
26 status: 401,
27 headers: { ...cors, 'Content-Type': 'application/json' },
28 });
29 }
30
31 const { content, accounts, scheduledAt } = await req.json();
32
33 // 2. Publish. The key is read here and never leaves the function.
34 const res = await fetch(`${OUTSTAND_API}/v1/posts`, {
35 method: 'POST',
36 headers: {
37 Authorization: `Bearer ${Deno.env.get('OUTSTAND_API_KEY')}`,
38 'Content-Type': 'application/json',
39 // Same key on every retry of the same logical post - see the retries section.
40 'Idempotency-Key': crypto.randomUUID(),
41 },
42 body: JSON.stringify({
43 containers: [{ content }],
44 accounts, // e.g. ['linkedin', 'x'] or account IDs
45 ...(scheduledAt ? { scheduledAt } : {}),
46 }),
47 });
48
49 const body = await res.json();
50
51 return new Response(JSON.stringify(body), {
52 status: res.status,
53 headers: { ...cors, 'Content-Type': 'application/json' },
54 });
55 } catch (err) {
56 return new Response(JSON.stringify({ error: String(err) }), {
57 status: 500,
58 headers: { ...cors, 'Content-Type': 'application/json' },
59 });
60 }
61});Two things in there earn their keep.
The auth check. An edge function is a public URL. Skip getUser() and you have built an endpoint that lets anyone on the internet post to your company's LinkedIn. This is the single most common mistake in this pattern.
The accounts array. It takes an account ID, a network name, or a username, and Outstand resolves each one to the connected accounts it matches. ['linkedin', 'x'] means "every connected LinkedIn and X account" — fine while you're the only user, and the thing you'll replace with explicit IDs in step 4.
Step 3 — Call it from your Lovable app
1import { supabase } from '@/integrations/supabase/client';
2
3async function publish(content: string) {
4 const { data, error } = await supabase.functions.invoke('publish-post', {
5 body: { content, accounts: ['linkedin', 'x'] },
6 });
7
8 if (error) throw error;
9 return data.post; // { id: '9dyJS', socialAccounts: [...], containers: [...] }
10}That's the whole client side. Wire it to a button, and your Lovable app publishes to two networks in one call.
The response includes a post.id. Keep it — it's how you check what happened, which turns out to matter.
Step 4 — Let your users connect their own accounts
Everything so far posts from your accounts. If you're building a product where each customer posts to their own — the far more common case — you need one more piece.
Connecting is a browser redirect. Send the user to Outstand's authorize URL for the network they picked:
1const network = 'linkedin'; // or 'x', 'instagram', 'tiktok', ...
2const orgId = 'YOUR_ORG_ID';
3const redirectUri = `${window.location.origin}/social/callback`;
4
5window.location.href =
6 `https://www.outstand.so/app/api/socials/${network}/${orgId}` +
7 `?redirect_uri=${encodeURIComponent(redirectUri)}`;They approve on the network's own consent screen, and Outstand sends them back to you with the result on the query string:
1https://yourapp.com/social/callback?success=true&account_id=acc_123&network_unique_id=1780...&username=janedoeIf it didn't work, the callback carries an error param instead — handle both.
Store account_id against your user row. In Supabase:
1create table social_accounts (
2 id uuid primary key default gen_random_uuid(),
3 user_id uuid not null references auth.users(id) on delete cascade,
4 outstand_account_id text not null,
5 network text not null,
6 username text,
7 connected_at timestamptz not null default now(),
8 unique (user_id, outstand_account_id)
9);
10
11alter table social_accounts enable row level security;
12
13create policy "users read their own accounts"
14 on social_accounts for select
15 using (auth.uid() = user_id);Now change your edge function to look up the caller's accounts instead of trusting whatever the client sent:
1const { data: linked } = await supabase
2 .from('social_accounts')
3 .select('outstand_account_id')
4 .eq('user_id', user.id);
5
6const accounts = linked?.map((a) => a.outstand_account_id) ?? [];
7if (accounts.length === 0) {
8 return new Response(JSON.stringify({ error: 'No connected accounts' }), {
9 status: 400, headers: { ...cors, 'Content-Type': 'application/json' },
10 });
11}The user ID comes from the verified session, the account IDs come from your database, and the client can no longer name a target it doesn't own. Same publish call, multi-tenant.
Step 5 — Schedule instead of posting now
Add scheduledAt — an ISO 8601 timestamp, UTC:
1await supabase.functions.invoke('publish-post', {
2 body: {
3 content: 'Shipping this on Tuesday.',
4 accounts: ['linkedin'],
5 scheduledAt: '2026-09-15T09:00:00Z',
6 },
7});Omit it, or pass a time in the past, and the post goes out immediately. The furthest you can schedule is 30 days out — a later timestamp is rejected with a 400. For a longer calendar, keep the schedule in your own table and enqueue each post as its send time comes inside the window. A scheduled post is the queue entry; there's no separate queue resource to manage.
Step 6 — Find out what actually happened
Publishing fans out to each account independently, so a post can partially succeed. Read the per-account status rather than assuming all-or-nothing:
1const res = await fetch(`https://api.outstand.so/v1/posts/${postId}`, {
2 headers: { Authorization: `Bearer ${Deno.env.get('OUTSTAND_API_KEY')}` },
3});
4const { post } = await res.json();
5
6for (const account of post.socialAccounts) {
7 switch (account.status) {
8 case 'published':
9 console.log(`${account.network}: ${account.platformPostId}`);
10 break;
11 case 'failed':
12 console.error(`${account.network}: ${account.error}`);
13 break;
14 default:
15 console.log(`${account.network}: ${account.status}`);
16 }
17}status is pending, published, failed or deleted. On success, platformPostId is the native ID on the network — store it if you want to deep-link to the post later or pull analytics for it. On failure, error says why, and the reason is usually an expired token, which means your user needs to reconnect that account.
If you'd rather not poll, Outstand can call you instead. Register a webhook and point post.published, post.error and account.token_expired at another edge function. That third event is the one worth wiring first: it fires when a social account's OAuth token fails to refresh, so it's how you prompt a user to reconnect before their next post fails rather than after.
X is the common case here. Its refresh tokens are single-use and rotate on every refresh, and they expire unpredictably — well before the documented six-month lifetime, with no clear reason. Reconnecting the account is the only reliable fix.
Retries, without duplicate posts
When a request times out you can't tell whether it landed. POST /v1/posts accepts an Idempotency-Key header for exactly this: the first attempt does the work, and every later attempt with the same key replays the first attempt's response verbatim — same status, same body, plus an Idempotency-Replayed: true header — instead of creating a second post. Keys are remembered for 24 hours from first use, scoped to your organization and to the endpoint. They're shared across every API key in the organization, so a retry sent with a newly rotated key still deduplicates correctly.
The rule that makes it work: generate the key once per logical post, not once per HTTP attempt.
1const idempotencyKey = crypto.randomUUID(); // outside the loop, deliberately
2
3for (let attempt = 0; attempt < 3; attempt++) {
4 const response = await fetch('https://api.outstand.so/v1/posts', {
5 method: 'POST',
6 headers: {
7 Authorization: `Bearer ${Deno.env.get('OUTSTAND_API_KEY')}`,
8 'Content-Type': 'application/json',
9 'Idempotency-Key': idempotencyKey,
10 },
11 body: JSON.stringify(payload),
12 });
13
14 if (response.status === 409) { // earlier attempt still in flight
15 const wait = Number(response.headers.get('Retry-After') ?? 1);
16 await new Promise((r) => setTimeout(r, wait * 1000));
17 continue;
18 }
19
20 return response;
21}Move that key generation inside the loop and every retry is a fresh request — which is how you end up posting the same thing three times.
What you can reach from here
The same call, the same accounts array, twelve networks: X, LinkedIn, Instagram, Facebook, Threads, TikTok, YouTube, Pinterest, Google Business Profile, Vimeo, Reddit and Bluesky. Add a network by adding its name to the array — no second SDK, no second OAuth app, no second review process.
Eight of the twelve run on Outstand's managed apps, so they connect through the redirect in step 4. The four bring-your-own-app networks are the exception. Bluesky is its own case again: no OAuth redirect at all, just a handle and an app password.
Media works the same everywhere: request an upload URL, PUT the bytes to it, confirm, then attach the returned URL to a container — media is referenced by its uploaded URL, not by an ID.
Which one do you actually need?
MCP connector | API + edge function | |
|---|---|---|
Who posts | You, from the Lovable chat | Your users, from your app |
Setup | Paste a URL, sign in | Secret, edge function, callback route |
Works in the published app | No | Yes |
Time | ~2 minutes | ~an hour |
They're not alternatives so much as different stages. Connect the MCP server now because it costs two minutes and immediately makes the build easier to talk about. Write the edge function when posting becomes a feature of the product rather than a thing you do about the product.
Where to go next
- Outstand MCP server — the full tool list and per-client setup.
- Backend integration guide — the same flow without Lovable in the picture, if you're wiring this into a different backend.
- Idempotency and webhooks — the two pages worth reading before you ship.
- API documentation — every endpoint, with the full request and response shapes.
If you build something with this, we'd like to see it: contact@outstand.so.