Before you start
Every recipe reads these three values from your shell. Keys start with plk_live_ (or plk_test_ for a test key) and belong to one workspace. Call the API from a server or a script, never from a browser: a key in a web page is a key anyone can copy. The full reference is the OpenAPI document at https://app.proofsource.co/v2/openapi.json.
export PROOFSOURCE_API_KEY="plk_live_..." # Settings → API Keys
export BRAND_ID="..." # an id from GET https://app.proofsource.co/v1/brands
export API="https://app.proofsource.co"Pull visibility into a sheet
One row per day and AI engine: answers, how many named you, your mention rate, share of voice and the competitor named most. The CSV opens in Google Sheets, Excel or Numbers. Dates are inclusive, up to one year.
curl -s "$API/v2/reports/daily?brand_id=$BRAND_ID&since=2026-10-01&until=2026-10-23&limit=200" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" \
| jq -r '["date","engine","answers","mentioned","mention_rate","share_of_voice","top_competitor"],
(.data[] | [.date,.engine,.answers,.mentioned,.mention_rate,.share_of_voice,.top_competitor])
| @csv' > visibility.csvimport { writeFile } from "node:fs/promises";
const api = process.env.API, key = process.env.PROOFSOURCE_API_KEY;
const cols = ["date", "engine", "answers", "mentioned", "mention_rate", "share_of_voice", "top_competitor"];
const rows = [cols.join(",")];
let cursor = "";
do {
const url = api + "/v2/reports/daily?brand_id=" + process.env.BRAND_ID +
"&since=2026-10-01&until=2026-10-23&limit=200" + (cursor ? "&cursor=" + cursor : "");
const res = await fetch(url, { headers: { Authorization: "Bearer " + key } });
if (!res.ok) throw new Error(res.status + " " + (await res.text()));
const page = await res.json();
for (const r of page.data) rows.push(cols.map((c) => JSON.stringify(r[c] ?? "")).join(","));
cursor = page.next_cursor;
} while (cursor);
await writeFile("visibility.csv", rows.join("\n"));List fixes and their state
The Fix it list as data: every fix Proofsource runs for the brand, its state, how many steps are done, and whether it waits for your Yes. Add state=waiting_for_you to see only the fixes that need someone, or state=proven for the wins.
curl -s "$API/v2/fixes?brand_id=$BRAND_ID&state=waiting_for_you" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" \
| jq '.data[] | {id, state, ask_text, steps: "\(.steps_done)/\(.steps_total)", needs_you, published}'const res = await fetch(process.env.API + "/v2/fixes?brand_id=" + process.env.BRAND_ID, {
headers: { Authorization: "Bearer " + process.env.PROOFSOURCE_API_KEY },
});
const { data, total_rows } = await res.json();
console.log(total_rows + " fixes");
for (const f of data) {
console.log(f.state.padEnd(16), f.steps_done + "/" + f.steps_total, f.needs_you ? "needs you" : "", f.ask_text);
}Start a fix and get its proof by webhook
Three calls and one webhook. First add a webhook for milestones (once). Then ask for a fix: nothing starts, you get the plan back. Start exactly that plan with its plan_id. When the fix is proven, your webhook gets a mission.proven event with the fix's id, and its receipt has the answers before and after. Both POST calls need a write key.
- Add the webhook. Keep signing_secret from the response: it is shown once.
- POST /v1/missions with brand_id and ask. Read the plan; nothing has started.
- POST /v1/missions with brand_id and the plan_id to start it.
- On mission.proven, GET the receipt for data.mission_id.
# 1. Once: a webhook that receives milestones, mission.proven among them
curl -s -X POST "$API/v1/notifications/channels" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" -H "Content-Type: application/json" \
-d '{"kind":"webhook","name":"Fix proofs","url":"https://example.com/proofsource","events":["milestone"]}'
# 2. Plan a fix (nothing starts)
curl -s -X POST "$API/v1/missions" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" -H "Content-Type: application/json" \
-d '{"brand_id":"'"$BRAND_ID"'","ask":"Get us named for best meeting notes app"}' | jq '{plan_id, what, where, plan}'
# 3. Start exactly that plan
curl -s -X POST "$API/v1/missions" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" -H "Content-Type: application/json" \
-d '{"brand_id":"'"$BRAND_ID"'","plan_id":"PLAN_ID_FROM_STEP_2"}'
# 4. After mission.proven arrives
curl -s "$API/v1/missions/MISSION_ID/receipt" -H "Authorization: Bearer $PROOFSOURCE_API_KEY" \
| jq '{outcome, line, before: .before, after: .after}'import { createServer } from "node:http";
import { verifyProofsourceSignature } from "./verify.js"; // the recipe below
const api = process.env.API, auth = { Authorization: "Bearer " + process.env.PROOFSOURCE_API_KEY };
createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks);
if (!verifyProofsourceSignature(req.headers["proofsource-signature"], body, process.env.WEBHOOK_SECRET)) {
res.writeHead(400).end();
return;
}
res.writeHead(200).end(); // answer fast; do the work after
const event = JSON.parse(body.toString("utf8"));
if (event.type !== "mission.proven") return;
const r = await fetch(api + "/v1/missions/" + event.data.mission_id + "/receipt", { headers: auth });
const receipt = await r.json();
console.log(receipt.outcome, receipt.line);
}).listen(3000);Read results and proof
The Results screen as data: one row per fix with where you stood before it, the newest reading after it and the verdict (proven, flat, checking and so on). A fix Proofsource ran has a proof_url; follow it for the receipt. A receipt answers 409 not_ready until the fix has a result.
curl -s "$API/v2/results?brand_id=$BRAND_ID" -H "Authorization: Bearer $PROOFSOURCE_API_KEY" \
| jq '.data[] | {question, verdict, line, before: .before.named, latest: .latest.named, proof_url}'const auth = { Authorization: "Bearer " + process.env.PROOFSOURCE_API_KEY };
const res = await fetch(process.env.API + "/v2/results?brand_id=" + process.env.BRAND_ID, { headers: auth });
const { data } = await res.json();
for (const row of data.filter((r) => r.verdict === "proven" && r.proof_url)) {
const receipt = await (await fetch(process.env.API + row.proof_url, { headers: auth })).json();
console.log(row.question, "|", receipt.line);
}Read the share report link
The link Share Report gives you, for a client portal or a weekly email. The first call finds the brand's newest finished report (an empty 204 means none yet: press Generate on Reports once); the second lists its live share links.
REPORT_ID=$(curl -s "$API/v1/geo-briefs/last-good?brand_id=$BRAND_ID" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" | jq -r '.id')
curl -s "$API/v1/reports/$REPORT_ID/share" -H "Authorization: Bearer $PROOFSOURCE_API_KEY" \
| jq -r '.share_tokens[] | select(.active) | .share_url'const api = process.env.API, auth = { Authorization: "Bearer " + process.env.PROOFSOURCE_API_KEY };
const last = await fetch(api + "/v1/geo-briefs/last-good?brand_id=" + process.env.BRAND_ID, { headers: auth });
if (last.status === 204) throw new Error("No report yet: generate one on Reports first.");
const report = await last.json();
const { share_tokens } = await (await fetch(api + "/v1/reports/" + report.id + "/share", { headers: auth })).json();
console.log(share_tokens.find((t) => t.active)?.share_url ?? "No live share link yet.");Track a new prompt
Add a buyer question to one of the brand's topics. It is asked on every engine in your plan from the next scan and counts toward your plan's question limit. Send an Idempotency-Key so a retry never adds it twice. Needs a write key.
TOPIC_ID=$(curl -s "$API/v1/topics" -H "Authorization: Bearer $PROOFSOURCE_API_KEY" \
| jq -r --arg b "$BRAND_ID" '[.topics[] | select(.brand_id == $b)][0].id')
curl -s -X POST "$API/v1/topics/$TOPIC_ID/prompts" \
-H "Authorization: Bearer $PROOFSOURCE_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"text":"What is the best AI meeting notes app for sales teams?"}'import { randomUUID } from "node:crypto";
const api = process.env.API, key = process.env.PROOFSOURCE_API_KEY;
const { topics } = await (await fetch(api + "/v1/topics", { headers: { Authorization: "Bearer " + key } })).json();
const topic = topics.find((t) => t.brand_id === process.env.BRAND_ID);
const res = await fetch(api + "/v1/topics/" + topic.id + "/prompts", {
method: "POST",
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json", "Idempotency-Key": randomUUID() },
body: JSON.stringify({ text: "What is the best AI meeting notes app for sales teams?" }),
});
console.log(res.status, await res.json());Verify a webhook signature
Every delivery carries ProofSource-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your channel's signing_secret. Compare in constant time, refuse a timestamp more than 5 minutes off, and use ProofSource-Delivery to drop a delivery you already handled. Workspaces under an agency's own name get the same scheme under X-Webhook-Signature.
# BODY is the raw request body, SIG the ProofSource-Signature header
T=$(echo "$SIG" | sed -E 's/.*t=([0-9]+).*/\1/')
V1=$(echo "$SIG" | sed -E 's/.*v1=([0-9a-f]+).*/\1/')
EXPECTED=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/.* //')
[ "$EXPECTED" = "$V1" ] && echo "valid" || echo "invalid"// verify.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyProofsourceSignature(header, rawBody, secret, toleranceSeconds = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}Page through results and stay under the limit
Every /v2 list carries next_cursor: pass it back as cursor until it is null. limit is 1 to 200 (50 by default). Each key may make 600 requests an hour; over that you get 429 with type rate_limited, so wait and retry rather than looping. Errors are application/problem+json with a stable type such as validation_failed, not_found or rate_limited.
export async function* pages(path, key = process.env.PROOFSOURCE_API_KEY) {
let cursor = "";
do {
const sep = path.includes("?") ? "&" : "?";
const res = await fetch(process.env.API + path + sep + "limit=200" + (cursor ? "&cursor=" + cursor : ""), {
headers: { Authorization: "Bearer " + key },
});
if (res.status === 429) { await new Promise((r) => setTimeout(r, 60_000)); continue; }
if (!res.ok) throw new Error((await res.json()).type);
const page = await res.json();
yield* page.data;
cursor = page.next_cursor;
} while (cursor);
}
for await (const fix of pages("/v2/fixes?brand_id=" + process.env.BRAND_ID)) console.log(fix.id, fix.state);