Platform integration
Your users click one button and a Meta campaign goes live for their business. You hold one Meta connection and one set of ad assets; each of your customers gets their own workspace under it, with their own brand profile, campaigns, and leads.
Quickstart
Pixero organizes this the way an agency is organized. You are an agency workspace. Each customer you serve is a brand workspace underneath it, holding exactly one brand profile. Every campaign, canvas, and lead belongs to the customer rather than to you, so their data never mixes.
The Meta assets live on the agency and are inherited downward. You set the ad account, Page, and pixel once. A new customer workspace starts with none of its own and launches on yours, which is why onboarding a customer needs no Meta ids at all. A customer workspace can still hold its own values, and those win when present.
Setup
Done once, before you write any code. You can do it yourself with the forms below, or hand it to your assistant.
The fast way: let your assistant do it
Three things, in this order. Everything after them is a conversation.
Step 1Create an API key with the platform scope
On the API keys page, tick platform before you create the key. It is off by default. Without it none of the tools on this page exist, and your assistant will say it cannot find them rather than that the key is missing a scope. Copy the key: it is shown once.
Step 2Add Pixero to your assistant
claude mcp add pixero --transport http https://pixero.ai/api/mcp \
--header "Authorization: Bearer pxr_your_key_here"Replace pxr_your_key_here with the key you just created. Run it in the directory you want to work in, then check it with claude mcp list, which should show pixero as connected.
export PIXERO_API_KEY=pxr_your_key_here
codex mcp add pixero \
--url https://pixero.ai/api/mcp \
--bearer-token-env-var PIXERO_API_KEYCodex takes the name of an environment variable rather than the key itself, and reads it each time it starts. Export it in the same shell, and put it somewhere permanent (your shell profile, or a .env your shell loads) or the connection will work now and fail tomorrow with an empty token. Check it with codex mcp list.
Step 3Say the words
Open your assistant in the directory you want to work in and say “Set up our Pixero integration”. It calls start_integration_setup and walks you through it one question at a time: which organization this is for, or a new one, then a handful about your business. Nothing is saved along the way. At the end it shows you the launch policy your answers produce, including the parts you did not state yourself, and only saves once you say it looks right.
Your assistant cannot skip ahead, answer on your behalf, or submit early. The server picks the next question and refuses anything else, so what gets saved is what you actually said.
start_integration_setup { }
→ {
"sessionId": "…",
"step": {
"id": "organization",
"question": "Which organization is this integration for?",
"options": [
{ "value": "…", "label": "Robinline" },
{ "value": "__new__", "label": "Create a new organization" }
],
"index": 1, "total": 11
},
"instruction": "Ask the person this question and wait for their answer."
}
answer_integration_setup { sessionId, questionId: "organization", answer: "__new__" }
→ { "step": { "id": "organizationName",
"question": "What would you like to name your organization?" } }
…one question at a time, then…
→ {
"status": "awaiting_review",
"review": {
"organization": "Robinline (new)",
"answers": [ { "question": "…", "answer": "…" } ],
"policy": {
"dailyBudgetFloor": 20, "dailyBudgetCeiling": 200,
"allowedObjectives": ["Leads"],
"optimizationGoal": "OFFSITE_CONVERSIONS",
"serviceAreaRequired": true
}
}
}
submit_integration_setup { sessionId, confirmed: true }Two steps it cannot do for you, because they need a person in a browser: connecting Meta and buying credits. Those come back marked needsHuman, so your assistant tells you rather than stalling.
Or do it yourself
Step 1Create a Pixero account and connect Meta
Sign up, then connect Meta from the app so Pixero can act on your ad account. Buy credits too: creative generation spends them, and a launch stops at the creative stage without a balance. If you do not have a business portfolio, ad account, and Page yet, the Business Manager setup guide covers it.
Step 2Create your agency workspace
This is the workspace every customer you onboard sits under, and what everything below hangs off. Name it after your company. You can read its id back any time with list_workspaces.
Loading your workspaces.
Step 3Set the Meta assets your customers inherit
Ad account, Page, and conversion pixel, set once on the agency and inherited by every customer you onboard. Set them here. There is no screen for this elsewhere in the app, because a workspace normally picks these up as a side effect of launching, which is no use to you: yours have to be in place before the first customer exists.
Loading your workspaces.
You can change them later without coming back here. set_workspace_meta_defaults does the same thing over MCP, and only the fields you pass are changed, so swapping a pixel does not clear the account and Page.
set_workspace_meta_defaults {
"workspaceId": "your-agency-workspace-id",
"adAccountId": "act_1234567890",
"pageId": "998877",
"pixelId": "1122334455667788"
}Step 4Answer the integration questionnaire
Nine questions about your business. Your answers become the rules your customers' campaigns run under: how much they can spend, what the ads must not say, and how the agent writes them.
You do not need to know how Meta works. We work the rest out, and nothing you answer here can loosen a rule.
Loading your workspaces.
Saving returns your integration name. Tag your agency workspace with it once, and every customer created under it inherits the policy.
set_workspace_integration {
"workspaceId": "your-agency-workspace-id",
"integration": "yourplatform"
}Step 5Create an API key
A platform integration needs every scope, including platform, which is off by default. That one is what puts the tools on this page into your assistant's hands at all: a key without it gets the ordinary set, and start_integration_setup will not exist to call. Create one on the API keys page and store it server side.
Usage
What your code does at runtime, once per customer. Four calls, and none of them takes a Meta id: your customer supplies a business name and a website, and everything else is inherited from the setup above or derived from their site.
Create their workspace
Idempotent by name, so it is safe to call on every launch. A returning customer resolves to their existing workspace instead of a second one. Passing integration means you do not have to carry your agency id in code, because the integration already knows which agency its customers belong to.
create_brand_workspace {
"name": "Acme Co.",
"integration": "yourplatform"
}
→ { "workspaceId": "fdac1dcb-...", "created": true }Give them a brand profile
analyze_brand_website reads their site and returns draft onboarding answers. Correct anything you already know from your own records, then save it. Re-submitting corrects the existing profile rather than creating a duplicate, and it starts the research pipeline that produces the positioning and creative direction the agent launches from.
analyze_brand_website { "url": "https://acme.example.com" }
→ draft answers: what they sell, who for, service areas, tone
submit_brand_profile {
"url": "https://acme.example.com",
"workspaceId": "fdac1dcb-...",
"answers": { ...the draft, corrected if you have better data... }
}Launch
One call takes a plain-English brief through strategy, creative generation, creative review, publishing, and activation. The daily budget you pass becomes the campaign-level cap Meta enforces, so that number is the ceiling.
autopilot_launch_campaign {
"workspaceId": "fdac1dcb-...",
"brief": "Lead-gen for Acme Co. Angle: free estimate, booked same week.",
"dailyBudget": 40,
"vendorRef": "acme-co",
"idempotencyKey": "yourplatform-launch-8f3a2c91",
"confirm": true
}| Field | Type | What it does |
|---|---|---|
| workspaceId | string | The customer's brand workspace. Everything the campaign needs is resolved from here. |
| brief | string | What to launch, in plain language: product, offer, audience, angle. |
| dailyBudget | number | Campaign-level daily cap in account currency. Must sit inside your integration's bounds. |
| idempotencyKey | string | Required. Retry the same key to resume; a new key starts a new campaign. |
| vendorRef | string | Your own id for this customer. Echoed on every lead webhook, so you can route leads without keeping a campaign map. |
| confirm | boolean | Must be true. This creates and runs real ads. |
| activate | boolean | Defaults to true. Pass false to stop at published but paused, for a review step of your own. |
Long launches can exceed a single request. When that happens the call returns the stage it reached plus a canvasId. Call again with the same idempotency key and it resumes where it stopped. Using a fresh key instead is how you end up with two live campaigns.
Receive the leads
Bind the Page to the customer's workspace and give Pixero the endpoint to deliver to. The call returns a signing secret. Every lead is POSTed to your endpoint with an X-Pixero-Signature header, and vendorRef rides along so you know whose lead it is.
set_lead_destination {
"workspaceId": "fdac1dcb-...",
"pageId": "998877",
"webhookUrl": "https://yourplatform.example.com/hooks/pixero-leads"
}
→ { "signingSecret": "whsec_..." }{
"source": "pixero",
"event": "lead.received",
"vendorRef": "acme-co",
"lead": {
"leadgen_id": "1122334455",
"page_id": "998877",
"form_id": "24680",
"campaign_id": "120712345678",
"full_name": "Jane Doe",
"email": "jane@example.com",
"phone": "+13105551234",
"field_data": { "zip_code": "90210" }
}
}Verify the signature before trusting the body:
const signature = req.get("X-Pixero-Signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));Leads are stored in Pixero either way. If delivery fails or no destination is set yet, they wait with a status you can read back with list_leads rather than being lost.
Show one customer their own status
Your interface is a button. Next to it, that customer wants to see whether their ads are running and what has come in. get_vendor_status answers exactly that, for one vendorRef: the id your own platform knows them by.
get_vendor_status { "vendorRef": "acme-co" }
→ {
"live": true,
"campaigns": [
{ "campaignId": "120712345678", "status": "ACTIVE", "dailyBudget": 40 }
],
"leadCount": 3,
"leads": [
{ "receivedAt": "...", "name": "Jane Doe", "email": "jane@example.com",
"phone": "+13105551234", "delivery": "delivered" }
]
}Scoped to that one id, so a customer sees their campaign and their leads and nobody else's. delivering reads Meta's effective status rather than ours, because a campaign can say active while its ad sets are paused or still in review. Budgets come back in real currency, not Meta's minor units.
Pass a vendorRef on every launch. It is the only thing tying a campaign back to your user, and it cannot be added afterwards: a campaign launched without one will never appear in this call.
The embed
The fastest way in, and the one most platforms should take. You mint a short-lived token on your server and render the widget with it. The button, the status, the spend, Pause and the budget all come from us, so there is no launch route, no status route, no webhook handler and no polling to write.
Plain HTML
One line, wherever the controls should appear.
<script src="https://pixero.ai/embed.js" data-token="pxe_..."></script>React
React does not execute an injected script tag, so render the iframe instead. Two small files, and the page that uses them.
Mints the widget's token. Server side only: it uses your API key, and returns the short-lived token that is safe to send to a browser.
import { pixero } from "@/lib/pixero";
/**
* Mint the token that the widget runs on. Server side only: it needs your API
* key, and the token it returns is the thing that is safe to send to a
* browser.
*
* Mint one per page render rather than storing it. They expire, and a fresh
* one costs a single call.
*/
export async function mintAdsToken(customer: {
id: string;
websiteUrl: string;
businessName?: string;
}) {
const mcp = await pixero();
const result = await mcp.call("create_embed_token", {
vendorRef: customer.id, // your own id for this customer
websiteUrl: customer.websiteUrl, // required
businessName: customer.businessName,
dailyBudget: 20, // starting value; the user can change it
});
if (!result.ok) throw new Error(result.error);
// The origin comes back with the token rather than being configured
// separately. Two settings that have to agree is one more than there
// should be: point PIXERO_MCP_URL at a local or preview server, mint a
// token there, and a hardcoded production iframe loads a page that token
// is not valid for.
return {
token: result.data.token as string,
origin: new URL(process.env.PIXERO_MCP_URL || "https://pixero.ai").origin,
};
}The React component. An iframe, not a script tag: React will not execute an injected script.
"use client";
import { useEffect, useRef, useState } from "react";
/**
* The whole integration, in React.
*
* An iframe rather than the <script> tag: React does not execute injected
* script elements, and an iframe keeps the widget's DOM and styles isolated
* from yours either way.
*
* The widget posts its height out as its content changes, because an iframe
* cannot size itself.
*/
export function PixeroAds({
token,
origin,
}: {
token: string;
/** Where the token was minted. Comes back from mintAdsToken. */
origin: string;
}) {
const [height, setHeight] = useState(180);
const frame = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== origin) return;
if (event.data?.type === "pixero:height") {
setHeight(Math.max(120, event.data.height + 8));
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [origin]);
return (
<iframe
ref={frame}
src={`${origin}/embed/ads?token=${encodeURIComponent(token)}`}
title="Ads"
style={{ width: "100%", border: 0, height }}
/>
);
}Putting them together: mint on the server, render on the client.
import { mintAdsToken } from "@/lib/pixero-ads";
import { PixeroAds } from "@/components/pixero-ads";
/**
* A server component: mint on the server, render on the client. Two lines of
* your own code, and the button, status, spend, pause and budget all come
* from the widget.
*/
export default async function CustomerAdsPage({ customer }: { customer: any }) {
const { token, origin } = await mintAdsToken(customer);
return <PixeroAds token={token} origin={origin} />;
}Mint a token per page render rather than storing one. They are scoped to a single customer and expire, which is what makes them safe to put in page source: the worst a stolen one can do is pause or rebudget that one business's ads, until it lapses.
The code
Prefer this when you want the campaign flow inside your own product rather than in an iframe. It is what the reference platform runs, not a sketch of it. If you are building with an assistant, have it call get_integration_code instead: the same files come back as a tool result it can write straight to disk.
MCP client. One session, one call method. Nothing else in your codebase should talk to Pixero directly.
/**
* Minimal MCP client for the Pixero server. Dependency-free on purpose: an
* integration is four JSON-RPC calls over one HTTP endpoint, and an SDK hides
* that.
*/
const MCP_URL = process.env.PIXERO_MCP_URL || "https://pixero.ai/api/mcp";
const API_KEY = process.env.PIXERO_API_KEY || "";
export type ToolResult =
| { ok: true; data: any }
| { ok: false; error: string };
class McpSession {
private sessionId: string | null = null;
private async rpc(method: string, params: unknown, notification = false) {
const body = notification
? { jsonrpc: "2.0", method, params }
: { jsonrpc: "2.0", id: Math.floor(Math.random() * 1e9), method, params };
const res = await fetch(MCP_URL, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
authorization: `Bearer ${API_KEY}`,
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
},
body: JSON.stringify(body),
});
const sid = res.headers.get("mcp-session-id");
if (sid) this.sessionId = sid;
if (notification) return null;
// Streamable HTTP may answer as SSE; the payload is the last data: line.
const text = await res.text();
const lines = text
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
const payload = lines.length ? lines[lines.length - 1] : text;
try {
return JSON.parse(payload);
} catch {
throw new Error(`Bad MCP response (${res.status}): ${text.slice(0, 300)}`);
}
}
async open() {
const init = await this.rpc("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "your-platform", version: "1.0.0" },
});
if (init?.error) throw new Error(`MCP initialize failed: ${init.error.message}`);
await this.rpc("notifications/initialized", {}, true);
}
async call(name: string, args: Record<string, unknown> = {}): Promise<ToolResult> {
const res = await this.rpc("tools/call", { name, arguments: args });
if (res?.error) return { ok: false, error: res.error.message || "MCP error" };
const result = res?.result;
const text = result?.content?.[0]?.text ?? "";
if (result?.isError) return { ok: false, error: text || "Tool returned an error" };
try {
return { ok: true, data: JSON.parse(text) };
} catch {
return { ok: true, data: text };
}
}
}
export async function pixero() {
if (!API_KEY) throw new Error("PIXERO_API_KEY is not set.");
const session = new McpSession();
await session.open();
return session;
}What the button calls: workspace, brand profile, launch. Stores the workspaceId and passes your own user id as vendorRef.
import { pixero } from "@/lib/pixero";
import { db } from "@/lib/db";
import { requireSession } from "@/lib/auth";
/**
* What the Launch ads button calls.
*
* customerId is YOUR id for this business. It becomes vendorRef on the
* campaign, which is the only thing tying that campaign back to your user, and
* it cannot be attached afterwards.
*/
export async function launchAdsFor(customerId: string, dailyBudget: number) {
// The id arrives from the browser, so ownership has to be proved here.
// Without this check any signed-in account can start ad spend on someone
// else's business, and read their leads back through the status route.
const session = await requireSession();
const customer = await db.customer.findFirst({
where: { id: customerId, accountId: session.accountId },
});
if (!customer) throw new Error("Unknown customer");
// Three things come from YOUR database, and only three:
// customer.id → vendorRef, how this campaign stays attributable
// customer.websiteUrl → required, everything else is derived from it
// customer.name → optional; fall back to the domain
// Find where these already live in your schema rather than adding new
// columns for them.
if (!customer.websiteUrl) throw new Error("A website URL is required");
const businessName =
customer.businessName?.trim() ||
new URL(customer.websiteUrl).hostname.replace(/^www\./, "");
const mcp = await pixero();
// 1. Their workspace. Call this on EVERY launch, not just the first: it is
// idempotent by name, so an existing customer resolves to the workspace
// they already have. Nothing to remember, no first-run branch.
const created = await mcp.call("create_brand_workspace", {
name: businessName,
integration: process.env.PIXERO_INTEGRATION!,
});
if (!created.ok) throw new Error(created.error);
const workspaceId = created.data.workspaceId;
// Store it. Every later call for this customer needs it, and looking it up
// by name again is how two customers with similar names get mixed up.
await db.customer.update({
where: { id: customerId },
data: { pixeroWorkspaceId: workspaceId },
});
// 2. Their brand profile, from their own site. Skip if you already did it:
// re-submitting corrects rather than duplicating, but it costs time.
if (!customer.pixeroProfileSubmitted) {
const draft = await mcp.call("analyze_brand_website", {
url: customer.websiteUrl,
});
if (!draft.ok) throw new Error(draft.error);
const saved = await mcp.call("submit_brand_profile", {
url: customer.websiteUrl,
workspaceId,
// Correct anything you already know better than a website scrape, but
// only within the accepted fields: anything else is dropped. The
// business name is not one of them; it comes from the workspace name
// you passed to create_brand_workspace.
answers: {
...draft.data.answers,
service_areas: customer.serviceAreas ?? draft.data.answers.service_areas,
},
});
if (!saved.ok) throw new Error(saved.error);
await db.customer.update({
where: { id: customerId },
data: { pixeroProfileSubmitted: true },
});
}
// 3. Launch. The idempotency key must be STABLE for this launch attempt: on
// a retry, the same key resumes; a new one starts a second live campaign.
const idempotencyKey = `launch-${customerId}-${customer.launchAttempt}`;
const launched = await mcp.call("autopilot_launch_campaign", {
workspaceId,
brief: `Lead-gen for ${businessName}. ${customer.pitch}`,
// The number the user typed, not a stored default.
dailyBudget,
vendorRef: customerId,
idempotencyKey,
confirm: true,
});
if (!launched.ok) throw new Error(launched.error);
// Store the canvasId permanently, not just for the resume. It is the handle
// for stopping and pausing later, and there is no way to look it up from a
// campaign id afterwards. Throwing it away is how a customer ends up with a
// campaign they cannot turn off.
await db.customer.update({
where: { id: customerId },
data: { pixeroCanvasId: launched.data.canvasId },
});
// A long launch can exceed one request. It returns the stage it reached;
// call again with the SAME key to carry on.
if (launched.data.stage && !launched.data.campaignId) {
return { status: "in_progress", stage: launched.data.stage, idempotencyKey };
}
return { status: "live", campaignId: launched.data.campaignId };
}One customer's campaign and leads, for the panel beside the button.
import { pixero } from "@/lib/pixero";
import { db } from "@/lib/db";
import { requireSession } from "@/lib/auth";
/**
* What renders next to the button: is it running, and what has come in.
* Scoped to one customer by the id your platform already knows them by.
*/
export async function statusFor(customerId: string) {
// Same ownership check as launch. This route returns leads, which are
// personal data belonging to someone else's business.
const session = await requireSession();
const owned = await db.customer.findFirst({
where: { id: customerId, accountId: session.accountId },
});
if (!owned) throw new Error("Unknown customer");
const mcp = await pixero();
const result = await mcp.call("get_vendor_status", {
vendorRef: customerId,
leadLimit: 25,
});
if (!result.ok) throw new Error(result.error);
return {
live: result.data.live,
campaigns: result.data.campaigns,
leads: result.data.leads,
};
}Stop and pause. Stop halts a launch that is still running; pause turns delivery off once it is live. Both need the canvasId stored at launch.
import { pixero } from "@/lib/pixero";
import { db } from "@/lib/db";
import { requireSession } from "@/lib/auth";
/**
* Stop and pause. They are different things and both are needed.
*
* stop — the launch is still running. Halts it before it creates anything
* else. Whatever already reached Meta stays there with delivery
* off, and is reused if you launch again.
* pause — the campaign is live. Turns delivery off. Nothing is deleted and
* it can be turned back on.
*
* Both act on the canvasId stored at launch, which is why it has to be kept.
*/
export async function setBudget(customerId: string, dailyBudget: number) {
const session = await requireSession();
const customer = await db.customer.findFirst({
where: { id: customerId, accountId: session.accountId },
});
if (!customer) throw new Error("Unknown customer");
const mcp = await pixero();
// Changes the live campaign in place. The integration's ceiling is enforced
// server side, so an over-cap value comes back as an error rather than
// quietly spending more.
const result = await mcp.call("set_vendor_budget", {
vendorRef: customerId,
dailyBudget,
});
if (!result.ok) throw new Error(result.error);
return result.data;
}
export async function controlAds(
customerId: string,
action: "stop" | "pause" | "resume",
) {
const session = await requireSession();
const customer = await db.customer.findFirst({
where: { id: customerId, accountId: session.accountId },
});
if (!customer?.pixeroCanvasId) throw new Error("Nothing to control");
const mcp = await pixero();
const result = await mcp.call("meta_launch_action", {
canvasId: customer.pixeroCanvasId,
// "cancel" is the mid-launch stop; "pause" and "activate" act on
// delivery once the campaign exists.
action:
action === "stop" ? "cancel" : action === "pause" ? "pause" : "activate",
...(action === "resume" ? { confirm: true } : {}),
});
// Stopping a launch that already finished is the ordinary race, not an
// error: the run completed between the click and this call.
if (!result.ok && action === "stop" && /not publishing/i.test(result.error)) {
return { ok: true, note: "Already finished; nothing was running to stop." };
}
if (!result.ok) throw new Error(result.error);
return { ok: true };
}Where leads arrive. Verifies the signature against the raw body before trusting anything.
import crypto from "node:crypto";
import { db } from "@/lib/db";
/**
* Where leads land. Register this URL once with set_lead_destination, which
* returns the signing secret.
*
* Read the RAW body. Parsing first and re-stringifying changes the bytes and
* the signature will never match.
*/
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get("x-pixero-signature") || "";
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.PIXERO_WEBHOOK_SECRET!)
.update(raw)
.digest("hex");
// Constant-time, and length-checked first because timingSafeEqual throws on
// a length mismatch.
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return new Response("Bad signature", { status: 401 });
const payload = JSON.parse(raw);
// vendorRef is the id you launched with, so no campaign map is needed.
// Upsert, not create: Meta redelivers and Pixero retries, so the same lead
// arrives more than once and a plain insert turns that into duplicates.
await db.lead.upsert({
where: { leadgenId: payload.lead.leadgen_id },
create: {
leadgenId: payload.lead.leadgen_id,
customerId: payload.vendorRef,
name: payload.lead.full_name,
email: payload.lead.email,
phone: payload.lead.phone,
raw: payload.lead,
},
update: {},
});
// Answer 200 quickly. Slow handlers get retried, and a retry you have
// already stored becomes a duplicate lead.
return Response.json({ received: true });
}The interface: a button, and what it did.
"use client";
import { useState, useEffect } from "react";
/**
* One customer's ads: the control surface beside your Launch button.
*
* What it deliberately does NOT ask for: the business name and the website.
* Your platform already has both. Making a user retype what you know is the
* fastest way to make a one-click feature feel like a form.
*
* What it must have: a daily budget the user sets, what has been spent, and a
* way to stop. Pause becomes Resume once delivery is off, because a paused
* campaign with no way back is a dead end.
*/
export function LaunchAds({ customerId }: { customerId: string }) {
const [status, setStatus] = useState<any>(null);
const [dailyBudget, setDailyBudget] = useState(20);
const [busy, setBusy] = useState<string | null>(null);
const refresh = async () => {
const res = await fetch(`/api/ads/status?customerId=${customerId}`);
const body = await res.json();
setStatus(body);
if (body?.dailyBudget) setDailyBudget(body.dailyBudget);
};
useEffect(() => {
void refresh();
// Meta takes a minute or two to report delivery and spend.
const timer = setInterval(refresh, 30000);
return () => clearInterval(timer);
}, [customerId]);
const post = async (path: string, body: object, label: string) => {
setBusy(label);
await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
setBusy(null);
void refresh();
};
const launching = busy === "launch";
const hasCampaign = Boolean(status?.campaigns?.length);
const money = (value: number | null) =>
value == null ? "—" : `$${value.toFixed(2)}`;
return (
<div>
{/* The budget is the user's decision and the only number they set. */}
<label>
Daily budget
<input
type="number"
min={1}
value={dailyBudget}
onChange={(event) => setDailyBudget(Number(event.target.value))}
/>
</label>
{hasCampaign && (
<div>
<strong>{status.live ? "Ads are running" : "Ads are paused"}</strong>
<div>Spent {money(status.totalSpend)} of {money(status.dailyBudget)} a day</div>
<div>{status.leadCount} leads so far</div>
<ul>
{status.leads.map((lead: any, i: number) => (
<li key={i}>
{lead.name} — {lead.email || lead.phone}
</li>
))}
</ul>
</div>
)}
{/* Stop halts a launch that is still building. Pause turns delivery off
once it is live, and flips to Resume so it can be turned back on. */}
{launching ? (
<button onClick={() => post("/api/ads/control", { customerId, action: "stop" }, "stop")}>
Stop
</button>
) : hasCampaign && status?.live ? (
<button onClick={() => post("/api/ads/control", { customerId, action: "pause" }, "pause")}>
Pause
</button>
) : hasCampaign ? (
<button onClick={() => post("/api/ads/control", { customerId, action: "resume" }, "resume")}>
Resume
</button>
) : null}
{/* Editing the budget on a live campaign, without relaunching. */}
{hasCampaign && (
<button
onClick={() => post("/api/ads/budget", { customerId, dailyBudget }, "budget")}
disabled={busy !== null}
>
Save budget
</button>
)}
{/* Stays available: a paused customer, or one who wants a second
campaign, has nowhere else to go. */}
<button
onClick={() => post("/api/ads/launch", { customerId, dailyBudget }, "launch")}
disabled={busy !== null}
>
{launching ? "Launching…" : hasCampaign ? "Launch another" : "Launch ads"}
</button>
</div>
);
}Adapt the names to your codebase, but not these. Each one is a failure that only appears in production.
- A launch needs exactly three things from YOUR database: your own id for the customer, their website URL, and optionally their business name. Find where those already live in the platform's schema before writing anything. Do not add new columns for data the platform already has, and do not ask the end user to retype it.
- The business name is optional. When you do not have one, derive it from the website's domain. The URL is not optional: the brand profile, the creative, and the destination all come from it.
- Call create_brand_workspace on every launch, not only the first. It is idempotent by name, so a returning customer resolves to the workspace they already have. There is no first-run branch to write and nothing to remember.
- The interface must include: a daily budget input, the spend so far against that budget, Stop while a launch is running, and Pause once it is live, with Pause becoming Resume when delivery is off. A launch button with no way to stop it is not finished.
- The daily budget is passed in at launch AND editable afterwards, through set_vendor_budget, without relaunching. A budget that can only be chosen once forces a relaunch to change a number, and a relaunch is a second campaign.
- Never write the budget bounds, currency, or objective into your own UI as literals. Read them from get_integration_policy (cache for a few minutes) and render what it returns. The policy lives on the Pixero side and can change without a deploy of yours; a hardcoded copy drifts until your page advertises a budget the widget then refuses.
- When reportingPending is true, say figures are still arriving instead of showing a zero. Meta reports spend several minutes late, so a campaign that launched fine shows $0.00 at first, and presenting that as a result makes a working launch look broken.
- Do not put the business name or website in the interface. The platform already has both; read them from its own records and pass them through. Asking a user to retype what you know turns one click into a form.
- Check that the signed-in account owns the customer before launching or reading status. The id comes from the browser, and without that check any user can start ad spend on someone else's business and read their leads.
- Store the workspaceId returned by create_brand_workspace against your customer. Every later call needs it.
- Pass your own user id as vendorRef on every launch. It is the only link from a campaign back to your user, and it cannot be added afterwards.
- Keep the idempotencyKey stable for a launch attempt. Retrying with the same key resumes; a new key starts a second live campaign.
- Verify X-Pixero-Signature against the RAW request body before trusting a lead. Parsing and re-stringifying changes the bytes.
- Poll get_vendor_status rather than waiting on the launch call. Meta takes a minute or two to report delivery.
Before you go live
The integration works with the four things below in place. Everything else on this page is detail.
| Field | Type | What it does |
|---|---|---|
| Meta connected | once | A person signs in to Pixero and connects Facebook. No agent can do this. |
| Credits | ongoing | Creative generation spends them. Without a balance a launch stops at the creative stage. |
| Ad account, Page, pixel | once | Set on your agency workspace in step 3. Every customer inherits them. |
| Integration policy | once | The questionnaire in step 4. It sets the budget ceiling and the rules every campaign launches under. |
What this does not do yet
Worth knowing before you put it in front of customers, because each of these is a question a customer will ask.
- Website campaigns show no leads. A conversion on your customer's own site is counted by the pixel; the person never reaches Pixero. The leads list only fills for on-Facebook forms. Spend, clicks and impressions still report either way.
- Spend lags by minutes. Meta reports late, so a campaign that just launched shows nothing for a while. The status call returns
reportingPendingso you can say so rather than showing a zero. - Nothing throttles a launch. A customer can press the button repeatedly and create several campaigns. Your daily budget ceiling caps each one, not the number of them.
- Pausing is per campaign, not per customer. Pause acts on the delivering campaign. A customer with several running needs several presses.
What the guardrails do
Your integration policy is enforced twice: once as a target, when the agent is briefed, and once as a verdict, at the moment of launch. It can only ever narrow what is allowed, never widen it. A launch that breaks a rule comes back with the specific violations rather than going out wrong.
Typical rules are a minimum and maximum daily budget, the objectives your vertical may use, claims that must not appear in copy, and whether a campaign must target a real service area rather than a whole country. The last one matters more than it sounds: without it, a local business quietly delivers nationally.
A note on cost
Reads are free. Generation spends credits from the account that holds the key, so a platform funds creative for its customers by holding credits centrally. Ad spend is billed by Meta to whoever owns the ad account, which under this model is you.
