FN.Pulseby FuturizeNow
On this page

Build with Lovable

A step-by-step guide for Authors Bureau. You will build, in Lovable, a place where your authors sign in to your app, connect their own LinkedIn, Instagram, Facebook and YouTube accounts, write a post, schedule it, and see what happened. The posting is done by the Pulse API. You do not need to be a developer: every step is something you paste.

Words we use: you are the partner. Your authors are the members. A connection is one social account a member linked. Anywhere you see https://pulse.futurizenow.co, that is a placeholder: we send you the real address together with your key. The full reference is the Pulse API docs.

1. What you are building

Three things sit between your author and the networks. Only the middle one is allowed to hold your Pulse key.

text · Select all and copy
 YOUR AUTHOR'S BROWSER
 your Lovable app
 (public: anyone can read
 its code)
        |
        |  "this person is signed in"
        v
 YOUR BACKEND
 Supabase edge functions
 (private: holds the Pulse key,
 checks who is signed in)
        |
        |  Authorization: Bearer key
        v
 PULSE (FuturizeNow)
        |
        v
 LINKEDIN / INSTAGRAM /
 FACEBOOK / YOUTUBE

Your authors do three things:

You will paste eight prompts into Lovable, one at a time. Lovable builds the pages and the backend from them.

2. The one rule

The Pulse API key never goes in the front end. Lovable writes React code that runs in your author's browser. Anything in that code, or in anything the browser downloads, is public. If the key is there, anyone can use it to post as any of your authors.

So:

You need these secrets:

SecretWhat it holds
PULSE_API_KEYThe key we send you. Starts with fnk_.
PULSE_BASE_URLThe Pulse address we send you, for example https://pulse.futurizenow.co. No slash at the end.
APP_URLYour app's own address, for example https://yourapp.lovable.app. Optional but recommended: it stops anyone using your backend to send a member to a different website.

Never type a secret into the Lovable chat, a code file, an email thread you do not control, or a public place. Prompt A1 below makes Lovable show you a private box for each one.

If the key is ever exposed (pasted in a chat, committed to a public repository, seen in browser code), tell us straight away at fasahath@gmail.com. We revoke it and send you a new one. Then you replace the PULSE_API_KEY secret. Nothing else changes.

3. The data

You need one small table that remembers which Pulse member belongs to which signed-in user.

sql · Select all and copy
create table public.pulse_members (
  user_id uuid primary key references auth.users (id) on delete cascade,
  member_id text not null unique,
  created_at timestamptz not null default now()
);

alter table public.pulse_members enable row level security;

create policy "Users read their own Pulse member"
  on public.pulse_members
  for select
  to authenticated
  using (auth.uid() = user_id);
-- No insert, update or delete policy on purpose: only the edge functions write here.

For pictures and video, you also need a storage bucket. Pulse downloads each file from a link, so the files must be readable from a public link, with file names nobody can guess:

sql · Select all and copy
insert into storage.buckets (id, name, public)
values ('post-media', 'post-media', true)
on conflict (id) do nothing;

create policy "Users upload into their own folder"
  on storage.objects for insert to authenticated
  with check (bucket_id = 'post-media' and (storage.foldername(name))[1] = auth.uid()::text);

create policy "Users delete their own files"
  on storage.objects for delete to authenticated
  using (bucket_id = 'post-media' and (storage.foldername(name))[1] = auth.uid()::text);

4. The edge functions

Eight files. One shared helper and seven small functions. Each function does the same three things: check who is signed in, find that person's member id in your table, make one call to Pulse.

The browser calls them as functions of your own project. It sends the person's sign-in automatically, and the request body for each is listed here. The two "list" functions accept GET or POST, the others POST or DELETE, because the Supabase browser library sends POST by default.

FunctionBody it acceptsPulse call it makes
pulse-ensure-membernonePOST /v1/partner/members with external_id (the user id), name, email, only the first time
pulse-connect-sessionnetworks?, return_url?POST /v1/partner/members/:id/connect-session
pulse-connectionsnoneGET /v1/partner/members/:id/connections
pulse-disconnectconnection_idDELETE /v1/partner/members/:id/connections/:cid
pulse-post-createtext, networks, texts?, media_urls?, youtube_title?, scheduled_at?, connection_ids?POST /v1/partner/members/:id/posts
pulse-postsnoneGET /v1/partner/members/:id/posts
pulse-post-cancelpost_idDELETE /v1/partner/members/:id/posts/:pid

What the shared helper does for all of them:

The other functions create the member by themselves the first time they need it, so you do not have to call pulse-ensure-member, but you can call it once after sign-up. Put each file at the path in its first line. Prompts A2 and A3 below paste them into Lovable for you. Here they are in full, so you can read them or hand them to a developer.

supabase/functions/_shared/pulse.ts

ts · Select all and copy
// supabase/functions/_shared/pulse.ts
// Shared by every pulse-* function. The Pulse key is read here, on the server, and nowhere else.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
  "Access-Control-Expose-Headers": "Retry-After",
};

export const NETWORKS = ["linkedin", "instagram", "facebook", "youtube"];

export function json(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { ...corsHeaders, "Content-Type": "application/json", ...extra },
  });
}

/** Same shape as Pulse's own errors: { error: { code, message, field? } } */
export function fail(status: number, code: string, message: string, field?: string): Response {
  return json({ error: { code, message, ...(field ? { field } : {}) } }, status);
}

const admin = () =>
  createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

export type User = { id: string; email?: string | null; user_metadata?: Record<string, unknown> };

/** Who is signed in? Asks Supabase to check the token. Nothing the browser says about identity is trusted. */
async function signedInUser(req: Request): Promise<User | null> {
  const token = (req.headers.get("Authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
  if (!token) return null;
  const { data, error } = await admin().auth.getUser(token);
  return error || !data?.user ? null : (data.user as User);
}

/** Wraps a function: CORS, method check, sign-in check, and a safe catch-all. */
export function serve(methods: string[], run: (req: Request, user: User) => Promise<Response>) {
  Deno.serve(async (req: Request) => {
    if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
    if (!methods.includes(req.method)) return fail(405, "method_not_allowed", "That request type isn't allowed here.");
    try {
      const user = await signedInUser(req);
      if (!user) return fail(401, "not_signed_in", "Please sign in again.");
      return await run(req, user);
    } catch (e) {
      console.error("pulse function error", e);
      return fail(500, "internal", "Something went wrong. Please try again.");
    }
  });
}

/** The JSON body of the request, or {} when there is none. */
export async function readJson(req: Request): Promise<Record<string, unknown>> {
  const text = await req.text();
  if (!text.trim()) return {};
  try {
    const v = JSON.parse(text);
    return v && typeof v === "object" && !Array.isArray(v) ? v : {};
  } catch {
    return {};
  }
}

/** Call Pulse. Always returns a Response (a network failure becomes a 502 in Pulse's own shape). */
export async function pulse(path: string, method = "GET", body?: unknown): Promise<Response> {
  const base = Deno.env.get("PULSE_BASE_URL")?.replace(/\/+$/, "");
  const key = Deno.env.get("PULSE_API_KEY");
  if (!base || !key) return fail(500, "server_setup", "This feature isn't set up yet.");
  try {
    return await fetch(`${base}/v1/partner${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${key}`,
        ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(25_000),
    });
  } catch {
    return fail(502, "provider_unavailable", "The posting service isn't answering right now. Please try again in a minute.");
  }
}

/**
 * Hand Pulse's answer to the browser: same status, same { error: { code, message, field } } body,
 * and the Retry-After header on a 429. A 204 becomes 200 { ok: true } so the browser has a body to read.
 */
export async function passThrough(res: Response): Promise<Response> {
  if (res.status === 204) return json({ ok: true });
  const text = await res.text();
  let parsed: any = null;
  try { parsed = JSON.parse(text); } catch { /* not JSON */ }
  if (!res.ok && !parsed?.error?.code) {
    return fail(502, "provider_unavailable", "The posting service isn't answering right now. Please try again in a minute.");
  }
  const extra: Record<string, string> = {};
  const retry = res.headers.get("Retry-After");
  if (res.status === 429 && retry) extra["Retry-After"] = retry;
  return json(parsed ?? {}, res.status, extra);
}

/**
 * The signed-in person's Pulse member id. It comes from OUR table, keyed by the verified user id.
 * The first time, the member is created in Pulse (safe to repeat) and remembered.
 */
export async function memberIdFor(user: User): Promise<string | Response> {
  const db = admin();
  const { data: row } = await db.from("pulse_members").select("member_id").eq("user_id", user.id).maybeSingle();
  if (row?.member_id) return row.member_id as string;

  const meta = user.user_metadata ?? {};
  const name = String(meta.full_name ?? meta.name ?? user.email ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 120);
  const email = user.email && user.email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email) ? user.email : undefined;
  const res = await pulse("/members", "POST", {
    external_id: user.id,
    ...(name ? { name } : {}),
    ...(email ? { email } : {}),
  });
  if (!res.ok) return passThrough(res);
  const member = await res.json();
  const { error } = await db.from("pulse_members").upsert({ user_id: user.id, member_id: member.id }, { onConflict: "user_id" });
  if (error) throw error;
  return member.id as string;
}

pulse-ensure-member

ts · Select all and copy
// supabase/functions/pulse-ensure-member/index.ts
// Makes sure the signed-in person exists in Pulse and in pulse_members. Safe to call any time.
import { json, memberIdFor, serve } from "../_shared/pulse.ts";

serve(["POST"], async (_req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;
  return json({ ready: true });
});

pulse-connect-session

ts · Select all and copy
// supabase/functions/pulse-connect-session/index.ts
// Body: { networks?: string[], return_url?: string }  ->  Pulse answers { url, expires_at }
import { NETWORKS, fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

function sameOrigin(a: string, b: string): boolean {
  try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
}

serve(["POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const b = await readJson(req);
  const body: Record<string, unknown> = {};

  if (b.networks !== undefined) {
    if (!Array.isArray(b.networks) || !b.networks.length || !b.networks.every((n) => NETWORKS.includes(n as string))) {
      return fail(422, "validation", "Choose at least one network.", "networks");
    }
    body.networks = b.networks;
  }
  if (b.return_url !== undefined && b.return_url !== null) {
    const url = typeof b.return_url === "string" ? b.return_url : "";
    const appUrl = Deno.env.get("APP_URL"); // optional secret: your app's address, e.g. https://yourapp.lovable.app
    if (!url.startsWith("https://") || url.length > 2048 || (appUrl && !sameOrigin(url, appUrl))) {
      return fail(422, "validation", "return_url must be a link to this app.", "return_url");
    }
    body.return_url = url;
  }
  return passThrough(await pulse(`/members/${member}/connect-session`, "POST", body));
});

pulse-connections

ts · Select all and copy
// supabase/functions/pulse-connections/index.ts
// Lists the signed-in person's connected accounts: { connections: [...] }
import { memberIdFor, passThrough, pulse, serve } from "../_shared/pulse.ts";

serve(["GET", "POST"], async (_req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;
  return passThrough(await pulse(`/members/${member}/connections`));
});

pulse-disconnect

ts · Select all and copy
// supabase/functions/pulse-disconnect/index.ts
// Body: { connection_id: "c_..." }  ->  disconnects that account (Pulse checks it belongs to this member)
import { fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

serve(["DELETE", "POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const { connection_id } = await readJson(req);
  if (typeof connection_id !== "string" || !/^c_[A-Za-z0-9_-]{1,100}$/.test(connection_id)) {
    return fail(422, "validation", "connection_id is missing or not valid.", "connection_id");
  }
  return passThrough(await pulse(`/members/${member}/connections/${encodeURIComponent(connection_id)}`, "DELETE"));
});

pulse-post-create

ts · Select all and copy
// supabase/functions/pulse-post-create/index.ts
// Body: { text, networks, texts?, media_urls?, youtube_title?, scheduled_at?, connection_ids? }
import { NETWORKS, fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

serve(["POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const b = await readJson(req);
  if (typeof b.text !== "string" || !b.text.trim()) return fail(422, "validation", "Write something to post.", "text");
  if (!Array.isArray(b.networks) || !b.networks.length || !b.networks.every((n) => NETWORKS.includes(n as string))) {
    return fail(422, "validation", "Choose at least one account.", "networks");
  }

  // Only these fields are passed on. The member id is never taken from the request.
  const body: Record<string, unknown> = { text: b.text, networks: b.networks };

  if (b.texts !== undefined) {
    if (!b.texts || typeof b.texts !== "object" || Array.isArray(b.texts)) return fail(422, "validation", "texts is not valid.", "texts");
    body.texts = b.texts;
  }
  if (b.media_urls !== undefined) {
    // Only files in YOUR public storage bucket are accepted.
    const prefix = `${Deno.env.get("SUPABASE_URL")}/storage/v1/object/public/`;
    if (!Array.isArray(b.media_urls) || b.media_urls.length > 10 || !b.media_urls.every((u) => typeof u === "string" && u.startsWith(prefix))) {
      return fail(422, "validation", "Attach files with the upload button.", "media_urls");
    }
    body.media_urls = b.media_urls;
  }
  if (b.youtube_title !== undefined) {
    if (typeof b.youtube_title !== "string") return fail(422, "validation", "youtube_title is not valid.", "youtube_title");
    body.youtube_title = b.youtube_title;
  }
  if (b.scheduled_at !== undefined) {
    if (typeof b.scheduled_at !== "string") return fail(422, "validation", "scheduled_at is not valid.", "scheduled_at");
    body.scheduled_at = b.scheduled_at;
  }
  if (b.connection_ids !== undefined) {
    if (!Array.isArray(b.connection_ids) || !b.connection_ids.every((c) => typeof c === "string")) {
      return fail(422, "validation", "connection_ids is not valid.", "connection_ids");
    }
    body.connection_ids = b.connection_ids;
  }
  return passThrough(await pulse(`/members/${member}/posts`, "POST", body));
});

pulse-posts

ts · Select all and copy
// supabase/functions/pulse-posts/index.ts
// Lists the signed-in person's 100 newest posts: { posts: [...] }
import { memberIdFor, passThrough, pulse, serve } from "../_shared/pulse.ts";

serve(["GET", "POST"], async (_req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;
  return passThrough(await pulse(`/members/${member}/posts`));
});

pulse-post-cancel

ts · Select all and copy
// supabase/functions/pulse-post-cancel/index.ts
// Body: { post_id }  ->  cancels a post that is still scheduled
import { fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

serve(["DELETE", "POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const { post_id } = await readJson(req);
  if (typeof post_id !== "string" || !/^[0-9a-f-]{36}$/i.test(post_id)) {
    return fail(422, "validation", "post_id is missing or not valid.", "post_id");
  }
  return passThrough(await pulse(`/members/${member}/posts/${post_id}`, "DELETE"));
});

5. The front end: eight prompts

Paste these into the Lovable chat one at a time, in order. Wait for Lovable to finish each one and look at the result before you paste the next. Each prompt stands on its own, so if something goes wrong you can paste a prompt again.

Prompt A is split in four so each message stays a sensible size. A1 to A4 build the backend and a small helper file. B, C and D build the three pages. E tidies everything.

If Lovable changes a file you asked it to create "exactly", say: "Restore that file exactly as I gave it."

Prompt A1 · Secrets, the table and the storage bucket Paste this into the Lovable chat. Select all and copy.
Add the Pulse integration backend, part 1 of 4: secrets, database and storage.

1. SECRETS. I need three secrets in Lovable Cloud (Supabase edge function secrets): PULSE_API_KEY, PULSE_BASE_URL and APP_URL. Ask me for each value using the private secret form. Never put these values in code, in a file, or in this chat, and never in anything that reaches the browser. Do not use a VITE_ prefix for them.

2. TABLE. Run this SQL as a migration, exactly as written:

```sql
create table public.pulse_members (
  user_id uuid primary key references auth.users (id) on delete cascade,
  member_id text not null unique,
  created_at timestamptz not null default now()
);

alter table public.pulse_members enable row level security;

create policy "Users read their own Pulse member"
  on public.pulse_members
  for select
  to authenticated
  using (auth.uid() = user_id);
```

Do not add any insert, update or delete policy. Only edge functions write to this table, using the service role.

3. STORAGE. Run this SQL as a migration, exactly as written:

```sql
insert into storage.buckets (id, name, public)
values ('post-media', 'post-media', true)
on conflict (id) do nothing;

create policy "Users upload into their own folder"
  on storage.objects for insert to authenticated
  with check (bucket_id = 'post-media' and (storage.foldername(name))[1] = auth.uid()::text);

create policy "Users delete their own files"
  on storage.objects for delete to authenticated
  using (bucket_id = 'post-media' and (storage.foldername(name))[1] = auth.uid()::text);
```

4. CONFIG. In supabase/config.toml add this block for each of these seven functions: pulse-ensure-member, pulse-connect-session, pulse-connections, pulse-disconnect, pulse-post-create, pulse-posts, pulse-post-cancel. The functions check the sign-in themselves, and the browser's pre-check request carries no token:

[functions.NAME]
verify_jwt = false

Confirm when done. Do not create the functions yet.
Prompt A2 · The shared helper and the first three functions Paste this into the Lovable chat. Select all and copy.
Add the Pulse integration backend, part 2 of 4. Create these four Supabase edge function files EXACTLY as written below. Do not change the code, rename anything, add dependencies, or move the secrets anywhere else. The Pulse key is read only with Deno.env.get in _shared/pulse.ts.

FILE 1: supabase/functions/_shared/pulse.ts

```ts
// supabase/functions/_shared/pulse.ts
// Shared by every pulse-* function. The Pulse key is read here, on the server, and nowhere else.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
  "Access-Control-Expose-Headers": "Retry-After",
};

export const NETWORKS = ["linkedin", "instagram", "facebook", "youtube"];

export function json(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { ...corsHeaders, "Content-Type": "application/json", ...extra },
  });
}

/** Same shape as Pulse's own errors: { error: { code, message, field? } } */
export function fail(status: number, code: string, message: string, field?: string): Response {
  return json({ error: { code, message, ...(field ? { field } : {}) } }, status);
}

const admin = () =>
  createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

export type User = { id: string; email?: string | null; user_metadata?: Record<string, unknown> };

/** Who is signed in? Asks Supabase to check the token. Nothing the browser says about identity is trusted. */
async function signedInUser(req: Request): Promise<User | null> {
  const token = (req.headers.get("Authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
  if (!token) return null;
  const { data, error } = await admin().auth.getUser(token);
  return error || !data?.user ? null : (data.user as User);
}

/** Wraps a function: CORS, method check, sign-in check, and a safe catch-all. */
export function serve(methods: string[], run: (req: Request, user: User) => Promise<Response>) {
  Deno.serve(async (req: Request) => {
    if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
    if (!methods.includes(req.method)) return fail(405, "method_not_allowed", "That request type isn't allowed here.");
    try {
      const user = await signedInUser(req);
      if (!user) return fail(401, "not_signed_in", "Please sign in again.");
      return await run(req, user);
    } catch (e) {
      console.error("pulse function error", e);
      return fail(500, "internal", "Something went wrong. Please try again.");
    }
  });
}

/** The JSON body of the request, or {} when there is none. */
export async function readJson(req: Request): Promise<Record<string, unknown>> {
  const text = await req.text();
  if (!text.trim()) return {};
  try {
    const v = JSON.parse(text);
    return v && typeof v === "object" && !Array.isArray(v) ? v : {};
  } catch {
    return {};
  }
}

/** Call Pulse. Always returns a Response (a network failure becomes a 502 in Pulse's own shape). */
export async function pulse(path: string, method = "GET", body?: unknown): Promise<Response> {
  const base = Deno.env.get("PULSE_BASE_URL")?.replace(/\/+$/, "");
  const key = Deno.env.get("PULSE_API_KEY");
  if (!base || !key) return fail(500, "server_setup", "This feature isn't set up yet.");
  try {
    return await fetch(`${base}/v1/partner${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${key}`,
        ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(25_000),
    });
  } catch {
    return fail(502, "provider_unavailable", "The posting service isn't answering right now. Please try again in a minute.");
  }
}

/**
 * Hand Pulse's answer to the browser: same status, same { error: { code, message, field } } body,
 * and the Retry-After header on a 429. A 204 becomes 200 { ok: true } so the browser has a body to read.
 */
export async function passThrough(res: Response): Promise<Response> {
  if (res.status === 204) return json({ ok: true });
  const text = await res.text();
  let parsed: any = null;
  try { parsed = JSON.parse(text); } catch { /* not JSON */ }
  if (!res.ok && !parsed?.error?.code) {
    return fail(502, "provider_unavailable", "The posting service isn't answering right now. Please try again in a minute.");
  }
  const extra: Record<string, string> = {};
  const retry = res.headers.get("Retry-After");
  if (res.status === 429 && retry) extra["Retry-After"] = retry;
  return json(parsed ?? {}, res.status, extra);
}

/**
 * The signed-in person's Pulse member id. It comes from OUR table, keyed by the verified user id.
 * The first time, the member is created in Pulse (safe to repeat) and remembered.
 */
export async function memberIdFor(user: User): Promise<string | Response> {
  const db = admin();
  const { data: row } = await db.from("pulse_members").select("member_id").eq("user_id", user.id).maybeSingle();
  if (row?.member_id) return row.member_id as string;

  const meta = user.user_metadata ?? {};
  const name = String(meta.full_name ?? meta.name ?? user.email ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 120);
  const email = user.email && user.email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email) ? user.email : undefined;
  const res = await pulse("/members", "POST", {
    external_id: user.id,
    ...(name ? { name } : {}),
    ...(email ? { email } : {}),
  });
  if (!res.ok) return passThrough(res);
  const member = await res.json();
  const { error } = await db.from("pulse_members").upsert({ user_id: user.id, member_id: member.id }, { onConflict: "user_id" });
  if (error) throw error;
  return member.id as string;
}
```

FILE 2: supabase/functions/pulse-ensure-member/index.ts

```ts
// supabase/functions/pulse-ensure-member/index.ts
// Makes sure the signed-in person exists in Pulse and in pulse_members. Safe to call any time.
import { json, memberIdFor, serve } from "../_shared/pulse.ts";

serve(["POST"], async (_req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;
  return json({ ready: true });
});
```

FILE 3: supabase/functions/pulse-connect-session/index.ts

```ts
// supabase/functions/pulse-connect-session/index.ts
// Body: { networks?: string[], return_url?: string }  ->  Pulse answers { url, expires_at }
import { NETWORKS, fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

function sameOrigin(a: string, b: string): boolean {
  try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
}

serve(["POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const b = await readJson(req);
  const body: Record<string, unknown> = {};

  if (b.networks !== undefined) {
    if (!Array.isArray(b.networks) || !b.networks.length || !b.networks.every((n) => NETWORKS.includes(n as string))) {
      return fail(422, "validation", "Choose at least one network.", "networks");
    }
    body.networks = b.networks;
  }
  if (b.return_url !== undefined && b.return_url !== null) {
    const url = typeof b.return_url === "string" ? b.return_url : "";
    const appUrl = Deno.env.get("APP_URL"); // optional secret: your app's address, e.g. https://yourapp.lovable.app
    if (!url.startsWith("https://") || url.length > 2048 || (appUrl && !sameOrigin(url, appUrl))) {
      return fail(422, "validation", "return_url must be a link to this app.", "return_url");
    }
    body.return_url = url;
  }
  return passThrough(await pulse(`/members/${member}/connect-session`, "POST", body));
});
```

FILE 4: supabase/functions/pulse-connections/index.ts

```ts
// supabase/functions/pulse-connections/index.ts
// Lists the signed-in person's connected accounts: { connections: [...] }
import { memberIdFor, passThrough, pulse, serve } from "../_shared/pulse.ts";

serve(["GET", "POST"], async (_req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;
  return passThrough(await pulse(`/members/${member}/connections`));
});
```

Then deploy all four.
Prompt A3 · The other four functions Paste this into the Lovable chat. Select all and copy.
Add the Pulse integration backend, part 3 of 4. Create these four Supabase edge function files EXACTLY as written below. Do not change the code, rename anything, or add dependencies. They use the shared helper from part 2 (supabase/functions/_shared/pulse.ts), which must stay unchanged.

FILE 1: supabase/functions/pulse-disconnect/index.ts

```ts
// supabase/functions/pulse-disconnect/index.ts
// Body: { connection_id: "c_..." }  ->  disconnects that account (Pulse checks it belongs to this member)
import { fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

serve(["DELETE", "POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const { connection_id } = await readJson(req);
  if (typeof connection_id !== "string" || !/^c_[A-Za-z0-9_-]{1,100}$/.test(connection_id)) {
    return fail(422, "validation", "connection_id is missing or not valid.", "connection_id");
  }
  return passThrough(await pulse(`/members/${member}/connections/${encodeURIComponent(connection_id)}`, "DELETE"));
});
```

FILE 2: supabase/functions/pulse-post-create/index.ts

```ts
// supabase/functions/pulse-post-create/index.ts
// Body: { text, networks, texts?, media_urls?, youtube_title?, scheduled_at?, connection_ids? }
import { NETWORKS, fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

serve(["POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const b = await readJson(req);
  if (typeof b.text !== "string" || !b.text.trim()) return fail(422, "validation", "Write something to post.", "text");
  if (!Array.isArray(b.networks) || !b.networks.length || !b.networks.every((n) => NETWORKS.includes(n as string))) {
    return fail(422, "validation", "Choose at least one account.", "networks");
  }

  // Only these fields are passed on. The member id is never taken from the request.
  const body: Record<string, unknown> = { text: b.text, networks: b.networks };

  if (b.texts !== undefined) {
    if (!b.texts || typeof b.texts !== "object" || Array.isArray(b.texts)) return fail(422, "validation", "texts is not valid.", "texts");
    body.texts = b.texts;
  }
  if (b.media_urls !== undefined) {
    // Only files in YOUR public storage bucket are accepted.
    const prefix = `${Deno.env.get("SUPABASE_URL")}/storage/v1/object/public/`;
    if (!Array.isArray(b.media_urls) || b.media_urls.length > 10 || !b.media_urls.every((u) => typeof u === "string" && u.startsWith(prefix))) {
      return fail(422, "validation", "Attach files with the upload button.", "media_urls");
    }
    body.media_urls = b.media_urls;
  }
  if (b.youtube_title !== undefined) {
    if (typeof b.youtube_title !== "string") return fail(422, "validation", "youtube_title is not valid.", "youtube_title");
    body.youtube_title = b.youtube_title;
  }
  if (b.scheduled_at !== undefined) {
    if (typeof b.scheduled_at !== "string") return fail(422, "validation", "scheduled_at is not valid.", "scheduled_at");
    body.scheduled_at = b.scheduled_at;
  }
  if (b.connection_ids !== undefined) {
    if (!Array.isArray(b.connection_ids) || !b.connection_ids.every((c) => typeof c === "string")) {
      return fail(422, "validation", "connection_ids is not valid.", "connection_ids");
    }
    body.connection_ids = b.connection_ids;
  }
  return passThrough(await pulse(`/members/${member}/posts`, "POST", body));
});
```

FILE 3: supabase/functions/pulse-posts/index.ts

```ts
// supabase/functions/pulse-posts/index.ts
// Lists the signed-in person's 100 newest posts: { posts: [...] }
import { memberIdFor, passThrough, pulse, serve } from "../_shared/pulse.ts";

serve(["GET", "POST"], async (_req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;
  return passThrough(await pulse(`/members/${member}/posts`));
});
```

FILE 4: supabase/functions/pulse-post-cancel/index.ts

```ts
// supabase/functions/pulse-post-cancel/index.ts
// Body: { post_id }  ->  cancels a post that is still scheduled
import { fail, memberIdFor, passThrough, pulse, readJson, serve } from "../_shared/pulse.ts";

serve(["DELETE", "POST"], async (req, user) => {
  const member = await memberIdFor(user);
  if (member instanceof Response) return member;

  const { post_id } = await readJson(req);
  if (typeof post_id !== "string" || !/^[0-9a-f-]{36}$/i.test(post_id)) {
    return fail(422, "validation", "post_id is missing or not valid.", "post_id");
  }
  return passThrough(await pulse(`/members/${member}/posts/${post_id}`, "DELETE"));
});
```

Then deploy all four.
Prompt A4 · The front-end helper file Paste this into the Lovable chat. Select all and copy.
Add the Pulse integration front-end helper, part 4 of 4. Create the file src/lib/pulse.ts EXACTLY as written below. It holds no secrets: it only calls my own edge functions as the signed-in user. Do not change it. If the import of the Supabase client path differs in this project, fix only that one import line.

```ts
// src/lib/pulse.ts
// Front-end helper. It holds NO secrets: it only calls your own edge functions as the signed-in person.
import { supabase } from "@/integrations/supabase/client";

export type PulseError = { code: string; message: string; field?: string };
export type PulseResult<T> =
  | { ok: true; data: T }
  | { ok: false; status: number; error: PulseError; retryAfter?: number };

export type NetworkId = "linkedin" | "instagram" | "facebook" | "youtube";
export type Connection = { id: string; network: NetworkId; label: string; status: "active" | "needs_reconnect"; connected_at: string };
export type PostNetwork = {
  network: NetworkId;
  connection_id: string | null;
  status: "scheduled" | "publishing" | "published" | "failed";
  external_url: string | null;
  error: string | null;
};
export type Post = {
  id: string;
  status: "scheduled" | "publishing" | "published" | "partial" | "failed";
  scheduled_at: string | null;
  text: string;
  error?: string;
  networks: PostNetwork[];
};

export const NETWORK_LABEL: Record<NetworkId, string> = {
  linkedin: "LinkedIn",
  instagram: "Instagram",
  facebook: "Facebook",
  youtube: "YouTube",
};

/** Text limits. Pulse checks them again when you post. */
export const LIMITS = {
  linkedin: 3000,
  instagram: 2200,
  instagramHashtags: 30,
  facebook: 63206,
  youtubeDescription: 5000,
  youtubeTitle: 100,
} as const;

/** Call one of your edge functions. Never throws: you always get { ok: true, data } or { ok: false, error }. */
export async function callPulse<T = any>(name: string, body: Record<string, unknown> = {}): Promise<PulseResult<T>> {
  try {
    const { data, error } = await supabase.functions.invoke(name, { body });
    if (!error) return { ok: true, data: data as T };
    const res: any = (error as any).context;
    if (res && typeof res.json === "function") {
      const parsed = await res.json().catch(() => null);
      const retry = Number(res.headers?.get?.("Retry-After"));
      return {
        ok: false,
        status: Number(res.status) || 500,
        error: parsed?.error ?? { code: "internal", message: "Something went wrong. Please try again." },
        retryAfter: Number.isFinite(retry) && retry > 0 ? retry : undefined,
      };
    }
    return { ok: false, status: 0, error: { code: "network", message: "We couldn't reach the server. Check your connection and try again." } };
  } catch {
    return { ok: false, status: 0, error: { code: "network", message: "We couldn't reach the server. Check your connection and try again." } };
  }
}

/** What to show a member for each error code. See the table in the guide. */
export function friendlyError(r: { error: PulseError; retryAfter?: number }): string {
  const { code, message } = r.error;
  switch (code) {
    case "not_signed_in": return "Please sign in again.";
    case "limit_reached": return message || "You've reached the limit for connected accounts.";
    case "not_connected": return "One of the accounts you picked needs to be connected again. Open Accounts to fix it.";
    case "ambiguous_account": return "You have more than one account on that network. Choose which one to use.";
    case "network_not_enabled": return "That network isn't available for your account.";
    case "validation": return message; // plain sentences written for people
    case "conflict": return "That post is being prepared or has already gone out. Wait a moment and look again.";
    case "rate_limited": return r.retryAfter ? `Lots of people are posting right now. Try again in ${r.retryAfter} seconds.` : "Lots of people are posting right now. Try again in a minute.";
    case "provider_unavailable": return "The posting service isn't answering right now. Please try again in a minute.";
    case "not_found": return "We couldn't find that. Refresh the page and try again.";
    case "network": return message;
    default: return "Something went wrong on our side. Please try again.";
  }
}

// ---- the connect window -------------------------------------------------------------

let stopListening: (() => void) | null = null;

/** Refetch when Pulse's window says something changed, and when the member comes back to this tab. */
function listenForChanges(expectedOrigin: string, popup: Window | null, onChange: () => void) {
  stopListening?.();
  let lastFocus = 0;
  const onMessage = (event: MessageEvent) => {
    if (event.origin !== expectedOrigin) return;            // only the connect window's own address
    if (popup && event.source !== popup) return;            // and only the window we opened
    if (!event.data || event.data.type !== "pulse.connection") return;
    onChange();                                             // never trust the message: the caller refetches from the server
  };
  const onFocus = () => {
    const now = Date.now();
    if (now - lastFocus < 3000) return;
    lastFocus = now;
    onChange();
  };
  window.addEventListener("message", onMessage);
  window.addEventListener("focus", onFocus);
  const timer = window.setTimeout(() => stopListening?.(), 35 * 60 * 1000);
  stopListening = () => {
    window.removeEventListener("message", onMessage);
    window.removeEventListener("focus", onFocus);
    window.clearTimeout(timer);
    stopListening = null;
  };
}

/**
 * Open the connect window. Call it straight from a button click.
 * Desktop: a popup (520x720). Phone, or popup blocked: the same tab, coming back to /accounts afterwards.
 * onChange is where you refetch the connections list.
 */
export async function startConnect(onChange: () => void, networks?: NetworkId[]): Promise<PulseResult<null>> {
  const small = window.matchMedia("(max-width: 640px)").matches;
  // Open the empty popup NOW, inside the click, so the browser allows it. Fill it in once we have the address.
  const popup = small ? null : window.open("", "pulse-connect", "width=520,height=720");
  const res = await callPulse<{ url: string; expires_at: string }>("pulse-connect-session", {
    ...(networks ? { networks } : {}),
    ...(popup ? {} : { return_url: `${window.location.origin}/accounts` }),
  });
  if (!res.ok) {
    popup?.close();
    return res;
  }
  const { url } = res.data;
  if (popup && !popup.closed) {
    popup.location.href = url;
    listenForChanges(new URL(url).origin, popup, onChange);
  } else {
    window.location.assign(url);
  }
  return { ok: true, data: null };
}
```

Do not build any page yet.

Check before you go on

Sign in to your app, then ask Lovable: "Call the pulse-connections edge function as me and show the raw result." You should see {"connections":[]}. If you see an error, go to Troubleshooting before continuing.

Prompt B · The Accounts page Paste this into the Lovable chat. Select all and copy.
Build an "Accounts" page at the route /accounts, only for signed-in users (send signed-out users to the sign-in page). It uses src/lib/pulse.ts (callPulse, friendlyError, startConnect, NETWORK_LABEL and the Connection type). It must never call Pulse directly and never contain any key.

PAGE TEXT
- Title: "Your accounts"
- Intro: "Connect the accounts you want to post to. You sign in on each network's own page. We never see your password."
- Under the button: "The sign-in page may show the posting service's name."

CONNECTIONS LIST
- On load, call callPulse("pulse-connections") ONCE and show the result. Do not call it again on a timer. Do not call it while the person types.
- One row per connection: a network badge (text label from NETWORK_LABEL, never colour alone), the account label, and a status chip. Status "active" shows "Connected". Status "needs_reconnect" shows "Needs reconnecting" with a short line: "Connect this account again to keep posting." Group or sort rows by network.
- Each row has a "Disconnect" button. It opens an in-page confirmation (not a browser alert): "Disconnect {network} ({label})? Posts scheduled to it will not go out." with the buttons "Yes, disconnect" and "Keep it". Yes calls callPulse("pulse-disconnect", { connection_id }) then reloads the list. If it fails, show friendlyError(result) in the page.
- Loading state: three grey placeholder rows. Empty state: "Nothing connected yet. Press Connect accounts to start."
- A small "Refresh" button that reloads the list (disabled for 5 seconds after a press).

CONNECT BUTTON
- A primary button "Connect accounts". On click it calls startConnect(reload) from src/lib/pulse.ts, where reload refetches the connections from the server. startConnect opens a popup (window.open, 520 by 720) and, on a phone or if the popup is blocked, sends the person to the connect page in the same tab instead.
- While it works, the button shows "Opening..." and is disabled.
- If it returns an error with code "limit_reached", show its message in a notice above the list, exactly as Pulse wrote it (for example "You've reached the limit of 3 accounts on LinkedIn.") plus: "Disconnect one to add another." For any other error show friendlyError(result).
- Next to the button show: "A small window opens. When you are done, close it and come back here."

WHEN THE PERSON COMES BACK
- The list is refetched from the server whenever (1) the connect window sends the message {type: "pulse.connection"}, (2) the person returns to this tab (window focus), (3) the page loads with ?status=connected or ?status=cancelled in the address (this happens after the same-tab flow), (4) the Refresh button is pressed. The message is only a hint: NEVER read an account name or any data from it and never add anything to the list from it. Always refetch. startConnect in src/lib/pulse.ts already checks event.origin against the connect window's own address; do not weaken that check.
- After ?status=connected, show a notice "Connected. Your accounts are up to date." Remove the ?status part from the address afterwards.

WHICH ACCOUNTS WORK (a short panel below the list, plain language)
- "LinkedIn: your personal profile works."
- "Instagram: a Business or Creator account. Instagram does not let any app post to a personal account. Switching is free in the Instagram app."
- "Facebook: a Page you manage. Facebook does not let any app post to a personal profile."
- "YouTube: your own channel. Videos only."

Add a link "Write a post" to /compose once at least one connection has status "active". Add "Accounts" to the main navigation if there is one.
Prompt C · The New post page Paste this into the Lovable chat. Select all and copy.
Build a "New post" page at the route /compose, only for signed-in users. It uses src/lib/pulse.ts (callPulse, friendlyError, LIMITS, NETWORK_LABEL, Connection, NetworkId) and the Supabase Storage bucket "post-media". It must never call Pulse directly and never contain any key.

LOAD
- On load call callPulse("pulse-connections") once. If the person has no connection with status "active", show: "Connect an account first." with a button to /accounts, and nothing else.

ACCOUNTS
- Show one checkbox per connection, grouped under a heading for each network (use NETWORK_LABEL). Label: the account label. A connection with status "needs_reconnect" is shown but disabled, with "Needs reconnecting" and a link to /accounts.
- Only one account per network can be chosen in one post. If the person ticks a second account on the same network, untick the first. Under any network that has more than one account show: "One account per network for each post. To post to two accounts on the same network, make two posts."

WRITING
- One text box "What do you want to say?" (max 20,000 characters).
- For each network that is ticked, a toggle "Different text for {network}". When on, show a second text box for that network. If a toggle is on and its box is empty, block posting with: "Write the text for {network}, or turn that toggle off."
- If YouTube is ticked, show a required field "YouTube title" (max 100) and a note that the text box becomes the video description.
- Live character counters under each text box, one per ticked network, counting the text that network will actually get (the network's own box if its toggle is on, otherwise the main box). Limits come from LIMITS: LinkedIn 3,000. Instagram 2,200 and no more than 30 hashtags (count words that start with #). Facebook 63,206. YouTube description 5,000 and title 100. Show "1,204 / 3,000". When over, turn the counter red AND add the words "too long by N" (do not rely on colour alone) and disable the Post button.

MEDIA
- A file picker "Add pictures or video" (images: JPG or PNG; video: MP4; up to 10 files). For each file: upload to the Supabase Storage bucket "post-media" at the path `${user.id}/${crypto.randomUUID()}.${extension}` (so the file name cannot be guessed), then take its public URL with getPublicUrl. Keep the list of public https URLs. Show a thumbnail or the file name, with a progress state and a "Remove" button (Remove also deletes the file from the bucket).
- Explain under the picker: "Your files are stored under a long random web address that is reachable without signing in until the post goes out. That is how the networks fetch them."
- Instagram ticked and no media: block with "Instagram posts need a picture or a video." YouTube ticked and no video: block with "YouTube posts need a video."

WHEN
- Two choices: "Post now" (default) and "Schedule for later". Schedule shows a date and time picker (type datetime-local) with min = now and max = 365 days ahead. Convert to an ISO string with a Z ending using new Date(value).toISOString() and send it as scheduled_at. For "Post now" send NO scheduled_at (Pulse then posts in about two minutes; say so: "About two minutes from now."). Show the person's time zone next to the picker.

SENDING
- On Post, call callPulse("pulse-post-create", { text, networks, connection_ids, texts, media_urls, youtube_title, scheduled_at }). networks = the distinct networks of the ticked connections. connection_ids = the ids of the ticked connections. Send texts only for networks with their toggle on, media_urls only if there are files, youtube_title only if YouTube is ticked, scheduled_at only when scheduling.
- Button states: "Post" / "Posting..." (disabled while the request runs, so a double click cannot send twice).
- On success show: "Done. Your post is scheduled for {local date and time}." with a link "See your posts" to /posts, and clear the form.
- On failure show friendlyError(result) in a notice above the Post button, and keep the form as it is. If the error has a field, put the message next to that field too (field names: text, networks, texts, media_urls, youtube_title, scheduled_at, connection_ids).
- If the error code is "ambiguous_account": reload the connections, then show an in-page choice (not a browser alert) listing, for each network that has more than one active account, radio buttons for those accounts, with "Choose which account to use" as the heading. When the person confirms, send the post again with connection_ids set to the chosen accounts.
- If the error code is "not_connected": show the message and a link to /accounts.
- If the error code is "rate_limited": show the message and disable the Post button for the number of seconds in retryAfter.

Add "New post" to the main navigation if there is one.
Prompt D · The Posts page Paste this into the Lovable chat. Select all and copy.
Build a "Posts" page at the route /posts, only for signed-in users. It uses src/lib/pulse.ts (callPulse, friendlyError, NETWORK_LABEL, Post). It must never call Pulse directly.

LIST
- On load call callPulse("pulse-posts"), which returns { posts: Post[] }, newest first (Pulse returns the 100 newest; there is no paging, so say "Showing your latest 100 posts." only when 100 are shown).
- One card per post: the text (first 280 characters, with "Show more" if longer), the date and time it goes out or went out (scheduled_at in the person's local time, with the time zone name), then one line per network in post.networks: the network name, a status chip, and the account where known.
- Chips use a word AND a colour, never colour alone: "Scheduled", "Publishing", "Published", "Failed". If a network is "published" and has external_url, show a link "View post" that opens that URL in a new tab (rel="noopener noreferrer"). If a network is "failed", show its error sentence in plain text under the line. Only ever show external_url if it starts with https://.
- The post as a whole can also be "partial" (some networks published, some failed): show a small label "Partly published". If the post has its own error sentence, show it.
- Empty state: "No posts yet." with a button "Write a post" to /compose. Loading state: three placeholder cards.

CANCEL
- A post whose status is "scheduled" has a "Cancel post" button. It opens an in-page confirmation (not a browser alert): "Cancel this post? It will not go out." with "Yes, cancel it" and "Keep it". Yes calls callPulse("pulse-post-cancel", { post_id }) then reloads the list. If the result is a conflict (409), show: "This post is being prepared or has already gone out. The list has been refreshed." and reload. Other errors: friendlyError(result).
- Posts that are not "scheduled" have no cancel button.

REFRESH
- Refresh the list every 30 seconds, but ONLY while at least one post has status "scheduled" or "publishing", and only while this browser tab is visible (document.visibilityState === "visible"). Never faster than every 30 seconds: Pulse allows 60 requests a minute for ALL users together. When no post is scheduled or publishing, do not poll at all. When the tab becomes visible again after more than 30 seconds, refresh once.
- A "Refresh" button that reloads the list (disabled for 10 seconds after a press). Show "Updated {time}" beside it.
- If a refresh fails, keep showing the last list and show a small line "We couldn't update just now." Do not clear the list. If the error is rate_limited, wait for retryAfter seconds before the next automatic refresh.
Prompt E · Polish Paste this into the Lovable chat. Select all and copy.
Polish the three pages /accounts, /compose and /posts and the navigation. Do not change any edge function, src/lib/pulse.ts, or the database.

- EMPTY STATES: each page has a friendly empty state with one clear next action (already specified; check they exist and read well).
- LOADING STATES: placeholder rows or cards while loading; buttons show a busy word and are disabled while a request runs; no layout jump when data arrives.
- ERRORS: every failed call shows a plain sentence from friendlyError in an area with role="alert". Never show a raw error code, a stack trace, or the word "Pulse" to the person. Never show a key.
- MOBILE: works at a width of 390 px with no sideways scrolling. Buttons are at least 44 px tall. The composer is one column. Date and time pickers and checkboxes are easy to tap. The sticky area, if any, never hides the Post button.
- ACCESSIBILITY: every input has a visible label tied to it; checkbox groups sit in a fieldset with a legend (the network name); status chips contain words, not only colour; success and progress notices use aria-live="polite"; in-page confirmation boxes move focus into the box, trap Tab inside it, close on Escape, and return focus to the button that opened them; all of it works with the keyboard only; the focus ring is clearly visible; text contrast is at least 4.5 to 1; respect prefers-reduced-motion.
- NAVIGATION: signed-in people see Accounts, New post and Posts. Signed-out people see none of these.
- TIME: show every date and time in the person's own time zone with the zone name; send times to the backend as UTC ISO strings (ending in Z).
- COPY: on /accounts keep the "which accounts work" panel and the line "The sign-in page may show the posting service's name." Add one more line under the panel: "You can disconnect any account at any time. Posts scheduled to it will not go out."

6. Every error, and what to do

Every Pulse error has the same shape: { "error": { "code": "...", "message": "...", "field": "..." } }. code is for your code, message is a plain sentence, field names the input at fault when there is one. Your edge functions pass these through unchanged, with the same HTTP status. The helper in src/lib/pulse.ts (friendlyError) already turns each code into the copy below.

HTTPCodeWhat the member seesWhat the app should do
401not_signed_in (from your own backend)"Please sign in again."Send them to the sign-in page.
401unauthorized"Something went wrong on our side. Please try again."The key is wrong or the secret is not set. Never show details to the member. Check PULSE_API_KEY, then tell us if it persists.
403forbidden_scope"Something went wrong on our side. Please try again."A call went to a path outside /v1/partner. This is a bug in the code. Do not show details.
400invalid_json"Something went wrong on our side. Please try again."The body was not a JSON object. A bug in the code. Do not show details.
400confirm_required(never shown)Only appears when deleting a member. This guide never deletes members, so it should not occur.
404not_found"We couldn't find that. Refresh the page and try again."Reload the list. It means the account or post is gone or is not theirs.
409conflict"That post is being prepared or has already gone out. Wait a moment and look again."Reload the posts list. Do not retry the cancel automatically.
409limit_reachedThe message as written, for example "You've reached the limit of 3 accounts on LinkedIn."Show it with "Disconnect one to add another." Do not open the connect window.
413body_too_large"Something went wrong on our side. Please try again."A request over 1 MB. Normal posts never reach this: media goes by link, not in the body.
422validationThe message as written. It is a plain sentence, for example "LinkedIn allows 3000 characters, this is 3412."Show it next to the field named in field. Keep the form as it is.
422network_not_enabled"That network isn't available for your account."Hide or disable that network. Ask us if it should be on.
422not_connected"One of the accounts you picked needs to be connected again. Open Accounts to fix it."Link to the Accounts page. Reload connections.
422ambiguous_account"You have more than one account on that network. Choose which one to use."Show the account picker, then send again with connection_ids.
429rate_limited"Lots of people are posting right now. Try again in N seconds."Wait for Retry-After seconds (your backend passes the header on). Disable the button meanwhile. Do not retry in a loop.
500internal"Something went wrong on our side. Please try again."Let them try again once. If it keeps happening, tell us the time.
502provider_unavailable"The posting service isn't answering right now. Please try again in a minute."Let them try again later. Nothing was lost: a failed disconnect or cancel leaves things as they were.
500server_setup (from your own backend)"Something went wrong on our side. Please try again."PULSE_API_KEY or PULSE_BASE_URL is missing. Check the secrets.
405method_not_allowed (from your own backend)"Something went wrong on our side. Please try again."A bug in the code.
0network (in the browser)"We couldn't reach the server. Check your connection and try again."Let them try again.

Failures on one network after a post is accepted are not API errors. They appear later on the post itself: the network's status becomes failed and its error says why in a plain sentence. Show that sentence on the Posts page.

7. What to tell your members

Put this text on your Accounts page, or in your onboarding. It saves a lot of support questions. Copy it as it is, or change the words to sound like you.

text · Select all and copy
Before you connect

You sign in to each network on its own page. Nobody at Authors Bureau or the posting service ever sees your password.

Which accounts work
- LinkedIn: your personal profile works. If you also manage a company page, you can pick it during sign-in when LinkedIn offers it.
- Instagram: you need a Business or Creator account. Instagram does not let any app post to a personal account. Switching is free in the Instagram app and takes a minute.
- Facebook: you need a Page you manage. Facebook does not let any app post to a personal profile or timeline. If you have no Page, you can create one for free.
- YouTube: your own channel. Videos only.

What you will see
The network's sign-in page may show the posting service's name. That is expected. It is how the network names the service that posts for you.

You stay in control
You can disconnect any account at any time on the Accounts page. Posts that were scheduled to that account will not go out.

8. Limits and costs

9. Test it, then go live

Test with a test member

  1. Sign up in your own app with an email you control. That is your test member.
  2. Open Accounts and connect your own LinkedIn first. It is the simplest.
  3. Write something harmless, such as "Testing a new tool. Ignore this.", and schedule it a few minutes ahead.
  4. Open Posts. Watch it go from Scheduled to Published. Open the link to the live post.
  5. Schedule another one an hour ahead, then cancel it. It should disappear from the list.
  6. Disconnect the account and connect it again.
  7. When you are done, delete the test member. Ask us to do it, or run this yourself from a terminal (it needs the member id from your pulse_members table and your key). It cancels the member's scheduled posts, disconnects their accounts and removes them. You cannot undo it, so never point it at a real author by accident.
bash · Select all and copy
curl -X DELETE "$PULSE_BASE_URL/v1/partner/members/MEMBER_ID?confirm=true"   -H "Authorization: Bearer $PULSE_API_KEY"

Then delete the matching row from pulse_members (or delete the test user).

Before you go live

10. Who sends what

We send you:

You send us:

Contact: Fasa, Futurize Now, fasahath@gmail.com. When something is not working, tell us the request you sent and the error you got back.

11. Troubleshooting

The popup does not open

Browsers block popups that do not start from a click. The "Connect accounts" button opens the window straight from the click, so this should not happen, but if the browser blocks it anyway the page sends the person to the connect window in the same tab, and they come back to the Accounts page afterwards. If it never opens on a computer, check that the person has not blocked popups for your site, and that the button calls startConnect directly, not after another step.

I get a 401 or "Something went wrong on our side" on everything

The key is wrong or the secret is missing. Check that PULSE_API_KEY and PULSE_BASE_URL exist as secrets, with no spaces and no slash at the end of the address, and that the functions were deployed after you set them. If the key was revoked, we send a new one.

429, "Lots of people are posting right now"

Your key made more than 60 requests in a minute. Find what is calling too often: a page refreshing faster than every 30 seconds, a call on every keystroke, or two tabs polling. The pages in this guide poll only while a post is waiting.

The member says the sign-in page shows another name

That is expected. The networks show the posting service's name on their own sign-in page, and we cannot hide it. Put the explanation from section 7 on your Accounts page so nobody is alarmed.

Instagram refuses to connect, or the post fails

The account is almost certainly a personal Instagram account. Instagram only lets apps post to Business or Creator accounts. The member switches the account type in the Instagram app's settings (it is free) and connects again. Facebook is the same: it needs a Page, not a personal profile.

A post stays "Scheduled"

First check the time zone. scheduled_at must be an ISO time with a Z or an offset, such as 2026-11-01T09:00:00Z. A datetime-local picker gives a time without a zone: convert it with new Date(value).toISOString() before you send it. If the time is right and the post is long overdue, tell us the post id and we will look.

The connected account does not appear after connecting

Press Refresh on the Accounts page. The list always comes from the server. If it still does not appear, the account may have been refused: a limit may be reached (you would have seen a message in the connect window) or the network did not complete the sign-in. Try again.

"Connect accounts" says the limit is reached

The member already has the most accounts allowed. They can disconnect one. If you want a higher number, ask us.