Service Accounts
Call the Tinct API from your own systems — no browser, no user login. A service account is a machine credential scoped to one workspace, so you can automate campaigns, audiences and publishing directly against our REST API.
https://api.tinct.ai,
and every snippet below already targets that host. Copy-paste as-is.
This guide takes you from creating a credential to a fully published, personalized campaign. It assumes you're comfortable with HTTP APIs and OAuth2. If you just want to explore the app, you don't need any of this — service accounts are for programmatic access.
Create a service account
Service accounts are created by a workspace admin from the Tinct app — not via the API (managing accounts is intentionally out of reach of a machine credential).
- In Tinct, open Settings → Service Accounts.
- Click New service account, give it a name (e.g.
Data pipeline), and pick the permissions it needs — start with the narrowest set (see Permissions). - On creation you'll see the Client ID and Client secret.
tinct_sa_
prefix is designed to trip secret scanners if it ever leaks.
Keep both values plus your workspace id in environment variables:
# the machine credential (from Settings → Service Accounts) export TINCT_CLIENT_ID="tsa_…" export TINCT_CLIENT_SECRET="tinct_sa_…" # your workspace (organization) id and the API host export TINCT_ORG_ID="00000000-0000-0000-0000-000000000000" export TINCT_BASE_URL="https://api.tinct.ai"
Get an access token
Exchange the client id/secret for a bearer token at
POST /oauth2/token using HTTP
Basic auth and the client_credentials grant.
# capture the token into TINCT_ACCESS_TOKEN for the calls below (jq parses the JSON) export TINCT_ACCESS_TOKEN=$(curl -s -u "$TINCT_CLIENT_ID:$TINCT_CLIENT_SECRET" \ -d 'grant_type=client_credentials' \ "$TINCT_BASE_URL/oauth2/token" | jq -r .access_token)
import os, requests
resp = requests.post(
f"{os.environ['TINCT_BASE_URL']}/oauth2/token",
auth=(os.environ["TINCT_CLIENT_ID"], os.environ["TINCT_CLIENT_SECRET"]),
data={"grant_type": "client_credentials"},
)
resp.raise_for_status()
access_token = resp.json()["access_token"]const basic = Buffer.from(
`${process.env.TINCT_CLIENT_ID}:${process.env.TINCT_CLIENT_SECRET}`
).toString("base64");
const resp = await fetch(`${process.env.TINCT_BASE_URL}/oauth2/token`, {
method: "POST",
headers: { Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials",
});
const { access_token } = await resp.json();The response is a JWT bearer token:
{ "access_token": "eyJraWQ…", "token_type": "Bearer", "expires_in": 1800 }
client_credentials. Cache the token in memory, reuse it, and fetch a new one when
it expires or on a 401. Never log it or write it to disk. The
setup helpers in the tutorial below cache it for you.
Make your first call
Send the token as a bearer header. Fetch your own workspace — every account can read it, and unlike a campaign list it's never empty on a fresh workspace:
curl -s -H "Authorization: Bearer $TINCT_ACCESS_TOKEN" \
-H "X-Tinct-OrganizationId: $TINCT_ORG_ID" \
"$TINCT_BASE_URL/api/v1/organizations/$TINCT_ORG_ID?fields=id,name,domain,type" | jqimport os, requests
org = os.environ["TINCT_ORG_ID"]
headers = {"Authorization": f"Bearer {access_token}", "X-Tinct-OrganizationId": org}
r = requests.get(f"{os.environ['TINCT_BASE_URL']}/api/v1/organizations/{org}",
params={"fields": "id,name,domain,type"}, headers=headers)
print(r.json())const org = process.env.TINCT_ORG_ID;
const r = await fetch(
`${process.env.TINCT_BASE_URL}/api/v1/organizations/${org}?fields=id,name,domain,type`,
{ headers: { Authorization: `Bearer ${access_token}`,
"X-Tinct-OrganizationId": org } });
console.log(await r.json());403, whatever the parameters say. The
X-Tinct-OrganizationId header is optional, but if you send it, it must match
the workspace you're addressing. On top-level list endpoints
(GET /api/v1/campaigns, /buyers, /audiences, …) you must
also pass organization_id=<workspace id> as a query parameter — the header
alone doesn't scope the list. Without it the request asks for a cross-workspace listing, which
a service account can never do, so it returns 403 insufficient_scope even though
reading each resource by id works fine. (/api/v1/blocks is the exception: it
scopes by campaign_id instead.) Add ?fields=id,name to any call to
trim the response.
fields — block_variants, sample_pages,
campaigns — gives you its first 10 items. Add .limit(N) (and
.offset(N) to page) on the nested field to get more:
?fields=block_variants.fields(id,type).limit(200). A page with 100 variants still
returns just 10 until you raise the limit. List endpoints like /api/v1/blocks take
the top-level limit and offset params instead, and default to 100.
Permissions
Permissions are chosen when the account is created. Use the short form (the workspace is
implicit) and grant the narrowest set that works — you can widen them later. An id position is
either a concrete id or * for "any".
| Resource pattern | Allowed actions |
|---|---|
buyer:* | read · create · update · delete · import · analyze-csv |
campaign:* | read · create · update · delete |
campaign:*:block:* | read · create · update · delete |
campaign:*:page:* | read · create · update · delete |
lead:* | read · create · update · delete · import · analyze-csv |
audience:* | read · create · update · delete · import · analyze-csv |
campaign:*:page:*) but the endpoints are top-level
(/api/v1/pages/{id}/_publish, …/_regenerate, …/block-variants/…).
There is no separate page:* permission to grant — campaign:*:page:*
covers them all. Grant it whenever you touch individual pages.
Anything outside this list — member management, billing, integrations, brands, or managing service accounts — cannot be granted to a machine credential. Every account also implicitly gets read access to its own workspace and to jobs (so it can poll import, generation and publish progress). The tutorial below assumes these grants:
campaign:*:read campaign:*:create campaign:*:update campaign:*:page:*:read campaign:*:page:*:update audience:*:read audience:*:create audience:*:import
Tutorial: build & publish a campaign
This walks the whole flow — create a campaign, analyze its landing page, build an audience,
generate personalized pages, and publish — over the API. Several steps run asynchronously:
they return immediately and do their work in a background job, so each is followed by a
poll that waits for completed before continuing.
curl track uses jq to
read ids out of responses, and runs in bash or zsh on Linux and macOS.
Setup — token & helpers
Fetch a token and define the helpers the steps reuse. For Python and Node, start the REPL in the same shell where you exported the variables from step 1, so they're visible through the environment.
# get a token (30-min lifetime) + a helper that adds the auth and workspace headers
export TINCT_TOKEN=$(curl -s -u "$TINCT_CLIENT_ID:$TINCT_CLIENT_SECRET" \
-d grant_type=client_credentials "$TINCT_BASE_URL/oauth2/token" | jq -r .access_token)
tinct() { curl -s -H "Authorization: Bearer $TINCT_TOKEN" \
-H "X-Tinct-OrganizationId: $TINCT_ORG_ID" "$@"; }
export LANDING_PAGE_URL="https://www.tinct.ai" # please replace it with your own landing pageimport os, time, requests
BASE = os.environ["TINCT_BASE_URL"]; ORG = os.environ["TINCT_ORG_ID"]
CREDS = (os.environ["TINCT_CLIENT_ID"], os.environ["TINCT_CLIENT_SECRET"])
LANDING_PAGE_URL = "https://www.tinct.ai" # please replace it with your own landing page
_tok = {"v": None, "exp": 0}
def token():
if _tok["v"] and time.time() < _tok["exp"] - 60:
return _tok["v"]
r = requests.post(f"{BASE}/oauth2/token", auth=CREDS,
data={"grant_type": "client_credentials"})
r.raise_for_status(); b = r.json()
_tok.update(v=b["access_token"], exp=time.time() + b["expires_in"]); return _tok["v"]
def api(method, path, **kw):
h = {"Authorization": f"Bearer {token()}", "X-Tinct-OrganizationId": ORG}
h.update(kw.pop("headers", {}))
r = requests.request(method, f"{BASE}{path}", headers=h, **kw)
r.raise_for_status(); return r.json() if r.content else None
def poll(path, done, tries=60, delay=3):
for i in range(tries):
v = api("GET", path)
if done(v): return v
print(f"polling {path} … ({i + 1}/{tries})")
time.sleep(delay)
raise TimeoutError(path)// paste into a Node 18+ REPL started in the same shell (top-level await works there)
const BASE = process.env.TINCT_BASE_URL, ORG = process.env.TINCT_ORG_ID;
const ID = process.env.TINCT_CLIENT_ID, SECRET = process.env.TINCT_CLIENT_SECRET;
const LANDING_PAGE_URL = "https://www.tinct.ai"; // please replace it with your own landing page
let tok = { v: null, exp: 0 };
async function token() {
if (tok.v && Date.now() < tok.exp - 60_000) return tok.v;
const basic = Buffer.from(`${ID}:${SECRET}`).toString("base64");
const r = await fetch(`${BASE}/oauth2/token`, { method: "POST",
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials" });
if (!r.ok) throw new Error(`token ${r.status}`);
const b = await r.json(); tok = { v: b.access_token, exp: Date.now() + b.expires_in * 1000 };
return tok.v;
}
async function api(method, path, body) {
const h = { Authorization: `Bearer ${await token()}`, "X-Tinct-OrganizationId": ORG };
if (body !== undefined) h["Content-Type"] = "application/json";
const r = await fetch(`${BASE}${path}`, { method, headers: h, body: body && JSON.stringify(body) });
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}: ${await r.text()}`);
const text = await r.text(); // some endpoints answer 200 with an empty body
return text ? JSON.parse(text) : null;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function poll(path, done, tries = 60, delay = 3000) {
for (let i = 0; i < tries; i++) {
const v = await api("GET", path);
if (done(v)) return v;
console.log(`polling ${path} … (${i + 1}/${tries})`);
await sleep(delay);
}
throw new Error(`timeout ${path}`);
}1 · Create the campaign
POST /api/v1/campaigns — the
campaign_context object is optional; it pre-fills the value props the AI setup step
would otherwise gather. Returns 200; we keep the new campaign id.
CAMPAIGN_ID=$(tinct -X POST "$TINCT_BASE_URL/api/v1/campaigns" -H "Content-Type: application/json" -d '{
"name": "Machine-driven campaign",
"landing_page_url": "'"$LANDING_PAGE_URL"'",
"organization": { "id": "'"$TINCT_ORG_ID"'" },
"campaign_context": {
"value_proposition": "Ship personalization without touching the page",
"goal": "Book qualified demos from named accounts",
"targeting_approach": "Mid-market growth teams",
"key_message": "One snippet, per-visitor landing pages",
"differentiation": "AI personalization with zero code changes",
"tone": "confident, concise",
"keywords": ["personalization", "abm", "landing page"]
}
}' | jq -r .id)
echo "campaign: $CAMPAIGN_ID"campaign = api("POST", "/api/v1/campaigns", json={
"name": "Machine-driven campaign",
"landing_page_url": LANDING_PAGE_URL,
"organization": {"id": ORG},
"campaign_context": {
"value_proposition": "Ship personalization without touching the page",
"goal": "Book qualified demos from named accounts",
"targeting_approach": "Mid-market growth teams",
"key_message": "One snippet, per-visitor landing pages",
"differentiation": "AI personalization with zero code changes",
"tone": "confident, concise",
"keywords": ["personalization", "abm", "landing page"],
},
})
cid = campaign["id"]; print("campaign:", cid)const campaign = await api("POST", "/api/v1/campaigns", {
name: "Machine-driven campaign",
landing_page_url: LANDING_PAGE_URL,
organization: { id: ORG },
campaign_context: {
value_proposition: "Ship personalization without touching the page",
goal: "Book qualified demos from named accounts",
targeting_approach: "Mid-market growth teams",
key_message: "One snippet, per-visitor landing pages",
differentiation: "AI personalization with zero code changes",
tone: "confident, concise",
keywords: ["personalization", "abm", "landing page"],
},
});
const cid = campaign.id;
console.log("campaign:", cid);2 · Analyze the landing page (async)
POST /api/v1/campaigns/{id}/landing-page-analysis identifies the personalizable blocks and streams progress as Server-Sent Events.
a. Trigger it and let the stream run to the end — the call blocks until analysis finishes:
# streams progress events, returns when analysis finishes
tinct -N -X POST "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/landing-page-analysis" \
-H "Accept: text/event-stream" -H "Content-Type: application/json" -d '{}'# open the stream and drain it — returns when analysis finishes
requests.post(f"{BASE}/api/v1/campaigns/{cid}/landing-page-analysis",
headers={"Authorization": f"Bearer {token()}", "X-Tinct-OrganizationId": ORG,
"Accept": "text/event-stream"}, json={}, stream=True).close()// open the stream and drain it — returns when analysis finishes
const stream = await fetch(`${BASE}/api/v1/campaigns/${cid}/landing-page-analysis`, { method: "POST",
headers: { Authorization: `Bearer ${await token()}`, "X-Tinct-OrganizationId": ORG,
Accept: "text/event-stream", "Content-Type": "application/json" }, body: "{}" });
await stream.body?.cancel();b. Confirm the analysis job finished cleanly — it should be completed
(and not failed):
while :; do STATUS=$(tinct "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID?fields=last_analysis_job.fields(status)" | jq -r '.last_analysis_job.status') echo "analysis: $STATUS" case "$STATUS" in completed|failed) break ;; esac sleep 3 done
poll(f"/api/v1/campaigns/{cid}?fields=last_analysis_job.fields(status)",
lambda c: c["last_analysis_job"]["status"] == "completed")await poll(`/api/v1/campaigns/${cid}?fields=last_analysis_job.fields(status)`,
(c) => c.last_analysis_job.status === "completed");3 · Create an audience and import leads (async import)
Create the audience, then import companies as leads. Use async: true — a large
synchronous import can exceed a gateway timeout — and poll the returned job_id.
AUDIENCE_ID=$(tinct -X POST "$TINCT_BASE_URL/api/v1/audiences" -H "Content-Type: application/json" \
-d '{ "name": "Machine-built audience", "organization": { "id": "'"$TINCT_ORG_ID"'" } }' | jq -r .id)
JOB_ID=$(tinct -X POST "$TINCT_BASE_URL/api/v1/audiences/_import" -H "Content-Type: application/json" -d '{
"type": "simple_list", "async": true,
"context": { "audience": { "id": "'"$AUDIENCE_ID"'" } },
"list": [
{ "company": { "domain": "salesforce.com", "name": "Salesforce" } },
{ "company": { "domain": "hubspot.com", "name": "HubSpot" } },
{ "company": { "domain": "datadoghq.com", "name": "Datadog" } },
{ "company": { "domain": "snowflake.com", "name": "Snowflake" } },
{ "company": { "domain": "zendesk.com", "name": "Zendesk" } },
{ "company": { "domain": "gong.io", "name": "Gong" } },
{ "company": { "domain": "pigment.com", "name": "Pigment" } },
{ "company": { "domain": "personio.com", "name": "Personio" } },
{ "company": { "domain": "contentsquare.com", "name": "Contentsquare" } },
{ "company": { "domain": "aircall.io", "name": "Aircall" } }
]
}' | jq -r .job_id)
while :; do
STATUS=$(tinct "$TINCT_BASE_URL/api/v1/jobs/$JOB_ID?fields=status" | jq -r .status)
echo "import: $STATUS"
case "$STATUS" in completed|failed) break ;; esac
sleep 3
doneaid = api("POST", "/api/v1/audiences",
json={"name": "Machine-built audience", "organization": {"id": ORG}})["id"]
companies = [("salesforce.com", "Salesforce"), ("hubspot.com", "HubSpot"), ("datadoghq.com", "Datadog"),
("snowflake.com", "Snowflake"), ("zendesk.com", "Zendesk"), ("gong.io", "Gong"),
("pigment.com", "Pigment"), ("personio.com", "Personio"),
("contentsquare.com", "Contentsquare"), ("aircall.io", "Aircall")]
job = api("POST", "/api/v1/audiences/_import", json={
"type": "simple_list", "async": True,
"context": {"audience": {"id": aid}},
"list": [{"company": {"domain": d, "name": n}} for d, n in companies],
})
poll(f"/api/v1/jobs/{job['job_id']}?fields=status",
lambda j: j["status"] in ("completed", "failed"))const aid = (await api("POST", "/api/v1/audiences",
{ name: "Machine-built audience", organization: { id: ORG } })).id;
const companies = [["salesforce.com", "Salesforce"], ["hubspot.com", "HubSpot"], ["datadoghq.com", "Datadog"],
["snowflake.com", "Snowflake"], ["zendesk.com", "Zendesk"], ["gong.io", "Gong"],
["pigment.com", "Pigment"], ["personio.com", "Personio"],
["contentsquare.com", "Contentsquare"], ["aircall.io", "Aircall"]];
const job = await api("POST", "/api/v1/audiences/_import", {
type: "simple_list", async: true,
context: { audience: { id: aid } },
list: companies.map(([domain, name]) => ({ company: { domain, name } })),
});
await poll(`/api/v1/jobs/${job.job_id}?fields=status`,
(j) => ["completed", "failed"].includes(j.status));4 · Attach the audience & generate pages
Attach the audience to the campaign, then generate. validate-audience samples the
first few pages; _generate-all covers the rest.
validate-audience returns
200 with a conflicts array and starts nothing. Re-call with
?exclude_conflicts=true to exclude those companies and proceed.
tinct -X PATCH "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID" -H "Content-Type: application/json" \
-d '{ "audience": { "id": "'"$AUDIENCE_ID"'" } }' > /dev/null
# sample; if it reports conflicts, exclude them and retry
CONFLICTS=$(tinct -X POST "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/validate-audience" \
| jq '(.conflicts // []) | length')
if [ "$CONFLICTS" != "0" ]; then
tinct -X POST "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/validate-audience?exclude_conflicts=true" > /dev/null
fi
# generate the remaining pages, then wait for generation to drain
tinct -X POST "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/_generate-all" > /dev/null
while :; do
LEFT=$(tinct "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID?fields=statistics.fields(generation_jobs_statistics.fields(queued_count,running_count))" | jq '.statistics.generation_jobs_statistics | ((.queued_count // 0) + (.running_count // 0))')
echo "pages still generating: $LEFT"
[ "$LEFT" = 0 ] && break
sleep 3
doneapi("PATCH", f"/api/v1/campaigns/{cid}", json={"audience": {"id": aid}})
if api("POST", f"/api/v1/campaigns/{cid}/validate-audience").get("conflicts"):
api("POST", f"/api/v1/campaigns/{cid}/validate-audience?exclude_conflicts=true")
api("POST", f"/api/v1/campaigns/{cid}/_generate-all")
poll(f"/api/v1/campaigns/{cid}?fields=statistics.fields(generation_jobs_statistics.fields(queued_count,running_count))",
lambda c: (lambda g: (g["queued_count"] or 0) + (g["running_count"] or 0) == 0)
(c["statistics"]["generation_jobs_statistics"]))await api("PATCH", `/api/v1/campaigns/${cid}`, { audience: { id: aid } });
if ((await api("POST", `/api/v1/campaigns/${cid}/validate-audience`)).conflicts?.length)
await api("POST", `/api/v1/campaigns/${cid}/validate-audience?exclude_conflicts=true`);
await api("POST", `/api/v1/campaigns/${cid}/_generate-all`);
await poll(`/api/v1/campaigns/${cid}?fields=statistics.fields(generation_jobs_statistics.fields(queued_count,running_count))`,
(c) => { const g = c.statistics.generation_jobs_statistics;
return (g.queued_count || 0) + (g.running_count || 0) === 0; });5 · Confirm the funnel steps & publish (async publish)
Confirm the four checkpoints, then publish. The publish job lands on
last_published_job; snippet_status reports whether the Tinct snippet
was detected on your landing page.
value_proposition after you create the campaign,
block_identification after the analysis, audience once it's attached,
and validation after generation completes. Sent all at once at the end, they land
out of order and the progress bar looks stuck.
for step in value_proposition block_identification audience validation; do tinct -X PUT "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/confirmed-step/$step" > /dev/null done tinct -X POST "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/_publish" > /dev/null while :; do STATUS=$(tinct "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID?fields=last_published_job.fields(status)" | jq -r '.last_published_job.status') echo "publish: $STATUS" case "$STATUS" in completed|failed) break ;; esac sleep 3 done tinct "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID?fields=status,snippet_status.fields(status,error)" | jq
for step in ("value_proposition", "block_identification", "audience", "validation"):
api("PUT", f"/api/v1/campaigns/{cid}/confirmed-step/{step}")
api("POST", f"/api/v1/campaigns/{cid}/_publish")
final = poll(f"/api/v1/campaigns/{cid}?fields=status,last_published_job.fields(status),snippet_status.fields(status)",
lambda c: c["last_published_job"]["status"] in ("completed", "failed"))
print("published:", final["status"], "snippet:", (final.get("snippet_status") or {}).get("status"))for (const step of ["value_proposition", "block_identification", "audience", "validation"])
await api("PUT", `/api/v1/campaigns/${cid}/confirmed-step/${step}`);
await api("POST", `/api/v1/campaigns/${cid}/_publish`);
const final = await poll(`/api/v1/campaigns/${cid}?fields=status,last_published_job.fields(status),snippet_status.fields(status)`,
(c) => ["completed", "failed"].includes(c.last_published_job.status));
console.log("published:", final.status, "snippet:", final.snippet_status?.status);6 · (Optional) Edit one page and republish it
Grab a generated page, tweak one block's content, and publish just that page. Needs the
campaign:*:page:* permission. A page that is already published can't be published
again as-is — unpublish it first, then publish. (A page that was never published skips the
_unpublish call.)
PAGE_ID=$(tinct "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID?fields=sample_pages.fields(id)" \
| jq -r '.sample_pages[0].id')
BLOCK_VARIANT_ID=$(tinct "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID?fields=block_variants.fields(id)" \
| jq -r '.block_variants[0].id')
tinct -X PATCH "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID/block-variants/$BLOCK_VARIANT_ID" \
-H "Content-Type: application/json" \
-d '{ "value": "<h1>Personalized just for you 👋</h1>" }' > /dev/null
# Already published? Unpublish before publishing again.
tinct -X POST "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID/_unpublish" > /dev/null
tinct -X POST "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID/_publish" > /dev/nullpage_id = api("GET", f"/api/v1/campaigns/{cid}?fields=sample_pages.fields(id)")["sample_pages"][0]["id"]
bv_id = api("GET", f"/api/v1/pages/{page_id}?fields=block_variants.fields(id)")["block_variants"][0]["id"]
api("PATCH", f"/api/v1/pages/{page_id}/block-variants/{bv_id}",
json={"value": "<h1>Personalized just for you 👋</h1>"})
# Already published? Unpublish before publishing again.
api("POST", f"/api/v1/pages/{page_id}/_unpublish")
api("POST", f"/api/v1/pages/{page_id}/_publish")const pageId = (await api("GET", `/api/v1/campaigns/${cid}?fields=sample_pages.fields(id)`))
.sample_pages[0].id;
const bvId = (await api("GET", `/api/v1/pages/${pageId}?fields=block_variants.fields(id)`))
.block_variants[0].id;
await api("PATCH", `/api/v1/pages/${pageId}/block-variants/${bvId}`,
{ value: "<h1>Personalized just for you 👋</h1>" });
// Already published? Unpublish before publishing again.
await api("POST", `/api/v1/pages/${pageId}/_unpublish`);
await api("POST", `/api/v1/pages/${pageId}/_publish`);Add a lead to a live campaign
The tutorial above builds a campaign from nothing. Day to day you do something narrower: the
campaign is already published, a new company shows up in your CRM, and you want that one
company to get its personalized page — without touching the pages already live. Four calls:
import the lead, find its page, generate it, publish it. The snippets reuse the
setup helpers and the CAMPAIGN_ID / AUDIENCE_ID
(cid / aid) variables from the tutorial.
POST /api/v1/audiences/_import is the
only call that creates the campaign page for a new lead. Adding lead ids through
PUT or PATCH /api/v1/audiences/{id} updates the audience and creates
no page at all — the audience and the campaign silently drift apart, and nothing tells
you. Always import.
1 · Import the lead into the existing audience
POST /api/v1/audiences/_import
— same shape as tutorial step 3, but pointed at the audience the campaign already uses and
with a one-item list. Keep async: true and poll the job.
JOB_ID=$(tinct -X POST "$TINCT_BASE_URL/api/v1/audiences/_import" -H "Content-Type: application/json" -d '{
"type": "simple_list", "async": true,
"context": { "audience": { "id": "'"$AUDIENCE_ID"'" } },
"list": [ { "company": { "domain": "figma.com", "name": "Figma" } } ]
}' | jq -r .job_id)
while :; do
STATUS=$(tinct "$TINCT_BASE_URL/api/v1/jobs/$JOB_ID?fields=status" | jq -r .status)
echo "import: $STATUS"
case "$STATUS" in completed|failed) break ;; esac
sleep 3
donejob = api("POST", "/api/v1/audiences/_import", json={
"type": "simple_list", "async": True,
"context": {"audience": {"id": aid}},
"list": [{"company": {"domain": "figma.com", "name": "Figma"}}],
})
poll(f"/api/v1/jobs/{job['job_id']}?fields=status",
lambda j: j["status"] in ("completed", "failed"))const job = await api("POST", "/api/v1/audiences/_import", {
type: "simple_list", async: true,
context: { audience: { id: aid } },
list: [{ company: { domain: "figma.com", name: "Figma" } }],
});
await poll(`/api/v1/jobs/${job.job_id}?fields=status`,
(j) => ["completed", "failed"].includes(j.status));tag for a company you already imported is ignored. Change it with
PATCH /api/v1/leads/{leadId}.
validate-audience conflict detection does not run on an import into a live
campaign: if another campaign in your workspace shares the same landing-page URL and already
targets this company, nothing warns you. Calling validate-audience on a live
campaign is not a safe substitute — it removes the pages of leads no longer in the
audience (unpublishing them) and only samples the first few pages. If you need the check, run it
before you go live.
2 · Find the new page
The import created the page, but not its content. Look the lead up by domain
(term matches company name or domain), then fetch its page —
GET /api/v1/pages with both
lead_id and campaign_id returns exactly zero or one page.
LEAD_ID=$(tinct "$TINCT_BASE_URL/api/v1/audiences/$AUDIENCE_ID/leads?term=figma.com&fields=id,company.fields(name,domain)" \
| jq -r '.[0].id')
PAGE_ID=$(tinct "$TINCT_BASE_URL/api/v1/pages?lead_id=$LEAD_ID&campaign_id=$CAMPAIGN_ID" | jq -r '.[0].id')
# fresh page: status is "waiting", no content yet
tinct "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID?fields=id,status,static_url" | jqlead_id = api("GET", f"/api/v1/audiences/{aid}/leads",
params={"term": "figma.com", "fields": "id,company.fields(name,domain)"})[0]["id"]
page_id = api("GET", f"/api/v1/pages?lead_id={lead_id}&campaign_id={cid}")[0]["id"]
# fresh page: status is "waiting", no content yet
print(api("GET", f"/api/v1/pages/{page_id}?fields=id,status,static_url"))const leadId = (await api("GET",
`/api/v1/audiences/${aid}/leads?term=figma.com&fields=id,company.fields(name,domain)`))[0].id;
const pageId = (await api("GET", `/api/v1/pages?lead_id=${leadId}&campaign_id=${cid}`))[0].id;
// fresh page: status is "waiting", no content yet
console.log(await api("GET", `/api/v1/pages/${pageId}?fields=id,status,static_url`));waiting with no content until you ask
for it in the next step. Nothing happens on its own.
3 · Generate that one page
POST
/api/v1/pages/{pageId}/_regenerate returns immediately and
generates in the background; poll the page's status until ready (or
error). Bulk alternative: POST /api/v1/campaigns/{id}/_generate-all is
safe to re-run on a live campaign — it skips published, excluded and already-generated pages, so
it only picks up the new ones. The jobs_submitted / jobs_skipped
counters in its response tell you which.
tinct -X POST "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID/_regenerate" > /dev/null while :; do STATUS=$(tinct "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID?fields=status" | jq -r .status) echo "page: $STATUS" case "$STATUS" in ready|error) break ;; esac sleep 3 done
api("POST", f"/api/v1/pages/{page_id}/_regenerate")
poll(f"/api/v1/pages/{page_id}?fields=status",
lambda p: p["status"] in ("ready", "error"))await api("POST", `/api/v1/pages/${pageId}/_regenerate`);
await poll(`/api/v1/pages/${pageId}?fields=status`,
(p) => ["ready", "error"].includes(p.status));4 · Publish that one page
POST
/api/v1/pages/{pageId}/_publish takes just this page live. You do
not need to re-publish the campaign — publishing a page brings the campaign back to
published on its own. The response carries the static_url you can now send out.
tinct -X POST "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID/_publish?fields=id,status,static_url,last_published_at" | jq
page = api("POST", f"/api/v1/pages/{page_id}/_publish?fields=id,status,static_url")
print(page["status"], page["static_url"])const page = await api("POST", `/api/v1/pages/${pageId}/_publish?fields=id,status,static_url`);
console.log(page.status, page.static_url);ready,
or when it is published and marked as needing republication after an edit.
Publishing a waiting page, or re-publishing a live page that is already in sync,
returns 400 — "Page … is not in a publishable state". An excluded page must
be re-included first (POST /api/v1/pages/{id}/_include). If the workspace has used
up its publication quota for the period, publishing returns 403.
Page links
Every personalized page has one public link, exposed as static_url:
{campaign.landing_page_url}?tinct_page_id={page.id}
It is your own landing page plus a query parameter. The Tinct snippet on that page reads
tinct_page_id and swaps in that company's personalized blocks — there is no
tinct-hosted URL for these pages. This is the link you put in the email, the ad, or the sequence.
static_url is one of the fields /api/v1/pages returns by default, so
you get it without asking. Add lead.fields(company.fields(name,domain)) when you
need the company on the same row — that's the join key for your outreach tool.
# one page tinct "$TINCT_BASE_URL/api/v1/pages/$PAGE_ID?fields=id,status,static_url" | jq # every live page in the campaign, as "domain <tab> link" tinct "$TINCT_BASE_URL/api/v1/pages?campaign_id=$CAMPAIGN_ID&limit=500&fields=id,status,static_url,last_published_at,lead.fields(company.fields(name,domain))" \ | jq -r '.[] | select(.status == "published") | [.lead.company.domain, .static_url] | @tsv'
pages = api("GET", f"/api/v1/pages?campaign_id={cid}&limit=500"
"&fields=id,status,static_url,last_published_at,lead.fields(company.fields(name,domain))")
for p in pages:
if p["status"] == "published":
print(p["lead"]["company"]["domain"], p["static_url"])const pages = await api("GET", `/api/v1/pages?campaign_id=${cid}&limit=500`
+ `&fields=id,status,static_url,last_published_at,lead.fields(company.fields(name,domain))`);
for (const p of pages.filter((p) => p.status === "published"))
console.log(p.lead.company.domain, p.static_url);static_url is returned
for any page status, including waiting and ready. Opening the
link of a page that isn't published just serves your generic landing page — no
error, no personalization, and you won't notice until a prospect doesn't convert. Send links
only for pages whose status is published. The CSV export below filters
for you; the JSON field does not.
For a whole campaign at once there's a ready-made export —
GET
/api/v1/campaigns/{campaignId}/_export-static-urls. It takes no
parameters, returns text/csv, and includes published pages only:
tinct "$TINCT_BASE_URL/api/v1/campaigns/$CAMPAIGN_ID/_export-static-urls" -o campaign-urls.csv company_name,variant_url,campaign_name,publication_date Figma,https://www.tinct.ai?tinct_page_id=6f1c9d2e-…,Machine-built campaign,2026-08-05T10:15:30Z
Two ways the link comes back empty:
| Symptom | Why |
|---|---|
static_url is null | the campaign has no landing_page_url. Set it when you create the campaign (POST /api/v1/campaigns) — it is system-managed afterwards, so sending it in a PUT/PATCH is silently ignored and the value never changes. To correct it, create a new campaign. |
| the CSV is header-only | no page in the campaign is published yet, or the campaign has no landing-page URL |
Managing accounts
These are admin actions in Settings → Service Accounts (a service-account token can't reach them):
- Edit — rename or change the permission set. A permission change applies to already-issued tokens within seconds, so no new token is needed.
- Rotate secret — issues a new secret and invalidates the old one immediately; deploy the new secret in the same step.
- Disable / Enable — disable is the emergency stop: live tokens stop working within seconds and no new token can be minted. Reversible with enable.
- Delete — revokes the credential permanently.
Errors
| Status | When | What to do |
|---|---|---|
401 at /oauth2/token | wrong or rotated-away secret | check the client secret |
400 at /oauth2/token | account disabled, or the client isn't a service account | re-enable it, or check the client id |
401 on /api/** | missing / expired / malformed token | fetch a new token |
403 on /api/** | ungranted action, another workspace, a disabled account, or a list call missing organization_id | check the account's permissions, and pass organization_id on list endpoints — not a token problem |
404 | unknown id, or a resource in another workspace | verify the id belongs to your workspace |
Best practices
- One account per integration. Never share a credential across tools or pipelines.
- Least privilege. Start read-only; add write actions only when you need them.
- Store the secret in a secret manager — never in source control or logs.
- Cache the token, refresh on expiry or
401. - Rotate periodically, and disable first on any suspected leak — that also kills live tokens.