tinct. API docs Base URL: https://api.tinct.ai

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.

Base URL. This page is served from 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.

1

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).

  1. In Tinct, open Settings → Service Accounts.
  2. 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).
  3. On creation you'll see the Client ID and Client secret.
The secret is shown once. Copy it immediately into a secret manager — it's hashed at rest and can never be displayed again, only rotated. The 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"
2

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 }
Tokens last 30 minutes and there is no refresh token — that's normal for 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.
3

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" | jq
import 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());
Org confinement. The token is locked to its own workspace — any request aimed at a different organization returns 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.
Nested lists return 10 by default. A collection nested inside fieldsblock_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 patternAllowed 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
Page endpoints are granted through the campaign. The permission is nested (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.

Run these in one session. Paste the blocks below one after another into the same terminal, Python REPL, or Node REPL — each step reuses variables set by the previous one (the campaign id, audience id, and so on). Start with the setup block. The 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 page
import 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
done
aid = 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.

Conflicts. If another campaign in your workspace shares the same landing-page URL and targets one of these companies, 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
done
api("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.

Confirm each step as you reach it. The Tinct app builds its progress bar from these four checkpoints. Doing them all in one loop (as below) is fine when you drive the whole flow by API and nobody opens the campaign in the app. If someone might, send each one as its stage finishes instead — 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/null
page_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.

Import — don't PATCH. 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
done
job = 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));
Re-importing is safe. Import is idempotent per company: a domain already present in your workspace resolves to the same lead, the audience is unchanged, and no second page is created. It still counts as a success, not an error, so a retrying pipeline needs no membership check. One consequence: a re-import does not update an existing lead — a new tag for a company you already imported is ignored. Change it with PATCH /api/v1/leads/{leadId}.
No conflict check on this path. The tutorial's 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" | jq
lead_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`));
New pages start empty. The import creates the page row but submits no generation job — the page sits at 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);
What can be published. A page is publishable when it is 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.

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);
A link exists before the page is live. 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:

SymptomWhy
static_url is nullthe 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-onlyno 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):

Errors

StatusWhenWhat to do
401 at /oauth2/tokenwrong or rotated-away secretcheck the client secret
400 at /oauth2/tokenaccount disabled, or the client isn't a service accountre-enable it, or check the client id
401 on /api/**missing / expired / malformed tokenfetch a new token
403 on /api/**ungranted action, another workspace, a disabled account, or a list call missing organization_idcheck the account's permissions, and pass organization_id on list endpoints — not a token problem
404unknown id, or a resource in another workspaceverify the id belongs to your workspace

Best practices

  1. One account per integration. Never share a credential across tools or pipelines.
  2. Least privilege. Start read-only; add write actions only when you need them.
  3. Store the secret in a secret manager — never in source control or logs.
  4. Cache the token, refresh on expiry or 401.
  5. Rotate periodically, and disable first on any suspected leak — that also kills live tokens.