VeraGen API
The VeraGen API lets your own code act on your VeraGen account: list your workflows, start runs and follow them, read your projects and Data Management, create pages, keep your Thrive contacts in step with your other systems, and read what the account has been billed.
It is a JSON API over HTTPS. Every request is made with an API key that an owner or admin of the account creates in the hub. A key acts for exactly one account, the one it was created in.
The base URL for every endpoint is:
https://api.veragen.ai/v1
curl "https://api.veragen.ai/v1/workflows?limit=25" \
-H "Authorization: Bearer $VERAGEN_API_KEY"const res = await fetch("https://api.veragen.ai/v1/workflows?limit=25", {
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}` },
});
const data = await res.json();import os, requests
res = requests.get(
"https://api.veragen.ai/v1/workflows",
params={"limit": 25},
headers={"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}"},
)
data = res.json(){
"data": [
{
"id": "3f6c1d2e-8a4b-4f7e-9c21-5d0e7a1b2c3d",
"name": "Weekly competitor digest",
"description": "Reads five competitor blogs and writes a summary page.",
"status": "active",
"project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"updated_at": "2026-10-02T15:04:05.123Z",
"last_run": {
"id": "c0ffee00-1234-4abc-9def-0123456789ab",
"status": "succeeded",
"at": "2026-10-03T06:00:00.000Z"
}
},
{
"id": "7b2e9f40-6c1d-4e8a-b3f5-2a9d0c7e1f64",
"name": "Lead enrichment",
"description": "",
"status": "draft",
"project_id": null,
"updated_at": "2026-09-30T09:12:44.000Z",
"last_run": null
}
],
"has_more": true,
"next_cursor": "WyIyMDI2LTA5LTMwVDA5OjEyOjQ0LjAwMDAwMFoiLCI3YjJlOWY0MC02YzFkLTRlOGEtYjNmNS0yYTlkMGM3ZTFmNjQiXQ"
}export VERAGEN_API_KEY="vg_live_…"A 200 with a data list means you are connected. A 401 means the key was not sent, was mistyped, or has been revoked.
curl "https://api.veragen.ai/v1/workflows?limit=25" \
-H "Authorization: Bearer $VERAGEN_API_KEY"const res = await fetch("https://api.veragen.ai/v1/workflows?limit=25", {
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}` },
});
const data = await res.json();import os, requests
res = requests.get(
"https://api.veragen.ai/v1/workflows",
params={"limit": 25},
headers={"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}"},
)
data = res.json(){
"data": [
{
"id": "3f6c1d2e-8a4b-4f7e-9c21-5d0e7a1b2c3d",
"name": "Weekly competitor digest",
"description": "Reads five competitor blogs and writes a summary page.",
"status": "active",
"project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"updated_at": "2026-10-02T15:04:05.123Z",
"last_run": {
"id": "c0ffee00-1234-4abc-9def-0123456789ab",
"status": "succeeded",
"at": "2026-10-03T06:00:00.000Z"
}
},
{
"id": "7b2e9f40-6c1d-4e8a-b3f5-2a9d0c7e1f64",
"name": "Lead enrichment",
"description": "",
"status": "draft",
"project_id": null,
"updated_at": "2026-09-30T09:12:44.000Z",
"last_run": null
}
],
"has_more": true,
"next_cursor": "WyIyMDI2LTA5LTMwVDA5OjEyOjQ0LjAwMDAwMFoiLCI3YjJlOWY0MC02YzFkLTRlOGEtYjNmNS0yYTlkMGM3ZTFmNjQiXQ"
}Send your key in the Authorization header of every request:
Authorization: Bearer vg_live_…
Keys start with vg_live_. An account can have up to 10 active keys; make one per system that calls the API, so you can revoke one without stopping the others.
A key can only call endpoints its scopes allow. Asking for something outside them is a 403 with the code insufficient_scope, and the message names the scope that is missing. A key can reach everything in the account that its scopes allow, in every project.
| Scope | Lets the key |
|---|---|
workflows:read | List and read workflows and their runs. |
workflows:run | Start workflow runs. Runs spend the account's AI credit. |
content:read | Read projects and Data Management items. |
content:write | Create pages in Data Management. |
contacts:read | Read Thrive contacts. |
contacts:write | Create and update Thrive contacts. |
usage:read | Read the account's billed usage. |
curl "https://api.veragen.ai/v1/workflows" \
-H "Authorization: Bearer $VERAGEN_API_KEY"const res = await fetch("https://api.veragen.ai/v1/workflows", {
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}` },
});
if (res.status === 401) throw new Error("Check the API key");import os, requests
res = requests.get(
"https://api.veragen.ai/v1/workflows",
headers={"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}"},
)
if res.status_code == 401:
raise SystemExit("Check the API key"){
"error": {
"type": "authentication",
"code": "invalid_api_key",
"message": "The API key is missing or not valid.",
"request_id": "req_5f0c2a9e1b7d4c3a8e6f1d20"
}
}Every error has the same shape, with an HTTP status that says what kind of problem it is:
type: the kind of error, which never changes for a given code;code: what went wrong, for your code to act on;message: a sentence for a person;param: the parameter at fault, when there is one;resource_id: on 409 contact_exists, the id of the contact that already has the email address;request_id: also sent as the X-Request-Id header on every response. Quote it to VeraGen support.An id that belongs to another account gets exactly the answer an id that does not exist gets: 404 resource_not_found. Unknown query parameters are refused with 400 unknown_parameter, and unknown body fields with 422 unknown_field, never silently ignored, so a typo cannot quietly change what you asked for. An update (PATCH) changes only the fields you send; send null to clear one.
| Status | Code | Meaning |
|---|---|---|
| 400 invalid_request | invalid_parameter | A query parameter has a value it cannot take. param names it. |
| 400 invalid_request | unknown_parameter | A query parameter this endpoint does not accept. Unknown parameters are refused, never ignored. |
| 400 invalid_request | invalid_id | An id that is not a UUID. |
| 400 invalid_request | invalid_cursor | The cursor is not one this endpoint gave out. |
| 400 invalid_request | invalid_json | The body is not valid JSON. |
| 400 invalid_request | missing_idempotency_key | Starting a run needs an Idempotency-Key header. |
| 400 invalid_request | missing_field | A required body field is missing. param names it. |
| 400 invalid_request | invalid_idempotency_key | The Idempotency-Key is not 8 to 64 printable characters without spaces. |
| 401 authentication | invalid_api_key | No API key, a malformed one, or one that does not exist. |
| 401 authentication | api_key_revoked | The key was revoked in the hub. |
| 401 authentication | api_key_expired | The key passed the expiry date it was created with. |
| 402 insufficient_credit | account_out_of_credit | The account is out of AI credit, so no run was started. An owner, or an admin with billing access, adds credit in Account → Billing. |
| 402 insufficient_credit | key_spend_cap_reached | This key has used its monthly spend cap. An owner or admin can raise it in Account → API keys. |
| 403 permission | insufficient_scope | The key does not hold the scope this endpoint needs. The message names it. |
| 403 permission | account_suspended | The account is suspended. Contact VeraGen support. |
| 403 permission | account_closed | The account has been closed. |
| 404 not_found | resource_not_found | Nothing with that id in this account. An id that belongs to another account gives exactly this answer. |
| 404 not_found | route_not_found | No endpoint at that path. |
| 404 not_found | api_not_available | The API is not open on this service yet. Every request gets this answer until it is. |
| 405 invalid_request | method_not_allowed | The path exists but not with that method. The Allow header lists the methods it takes. |
| 409 conflict | idempotency_in_progress | A request with this Idempotency-Key is still being handled. Retry with the same key in a few seconds. |
| 409 conflict | idempotency_outcome_unknown | The first request with this Idempotency-Key did not finish (it timed out or failed part way), so whether it started the run or created the page is unknown. It is never run again: list the workflow's runs or the project's items to see whether it did, and use a new key if it did not. |
| 409 conflict | contact_exists | Another contact in this account already has that email address. resource_id is its id: update it with PATCH instead. |
| 409 conflict | contact_do_not_contact | The address belongs to someone who was erased from Thrive at their request. They stay on do-not-contact: the API cannot add them again or change them. |
| 409 conflict | opt_out_locked | The contact has opted out of email. The API cannot opt them back in; a person can, in Thrive. |
| 413 invalid_request | body_too_large | The body is larger than 1 MB. |
| 413 invalid_request | page_too_large | The page would be larger than a page can be once converted to HTML. Split it into two pages. |
| 415 invalid_request | unsupported_media_type | A body that is not application/json. |
| 422 invalid_request | unknown_field | The body has a field this endpoint does not take. param names it. Unknown fields are refused, never ignored. |
| 422 invalid_request | invalid_body | The body is not a JSON object. |
| 422 invalid_request | invalid_field | A body field has a value it cannot take. param names it. |
| 422 invalid_request | workflow_empty | The workflow has no blocks, so there is nothing to run. |
| 422 invalid_request | idempotency_key_reused | This Idempotency-Key was already used for a different request. Use a new key for a new request. |
| 429 rate_limit | rate_limited | Too many requests for this key in the current minute. Wait for Retry-After seconds. |
| 429 rate_limit | too_many_failed_auth | Too many requests with a wrong key from one address in a minute. |
| 429 rate_limit | run_rate_limited | The account has started as many runs through the API in the last minute as its plan allows. |
| 429 rate_limit | concurrency_limited | The account already has as many API runs queued or running as its plan allows. Start another when one finishes. |
| 500 api_error | internal_error | Something went wrong on our side. Retry with backoff; quote the request_id to support. |
curl -i "https://api.veragen.ai/v1/workflows?cursor=not-a-cursor" \
-H "Authorization: Bearer $VERAGEN_API_KEY"const res = await fetch("https://api.veragen.ai/v1/workflows?cursor=not-a-cursor", {
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}` },
});
if (!res.ok) {
const { error } = await res.json();
console.error(error.code, error.message, error.request_id);
}import os, requests
res = requests.get(
"https://api.veragen.ai/v1/workflows",
params={"cursor": "not-a-cursor"},
headers={"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}"},
)
if not res.ok:
err = res.json()["error"]
print(err["code"], err["message"], err["request_id"]){
"error": {
"type": "invalid_request",
"code": "invalid_cursor",
"message": "cursor is not valid",
"param": "cursor",
"request_id": "req_5f0c2a9e1b7d4c3a8e6f1d20"
}
}Lists come a page at a time. Ask for up to 100 items with limit (the default is 25). Each page says whether there is more:
has_more: true when there is another page;next_cursor: pass it back as cursor to get that page, with the same filters.A cursor is opaque: use it as it is, and only with the endpoint and filters that gave it out. A cursor that has been changed is 400 invalid_cursor. Items that share a timestamp are never repeated or skipped across pages.
# First page
curl "https://api.veragen.ai/v1/workflows?limit=50" \
-H "Authorization: Bearer $VERAGEN_API_KEY"
# Next page: the next_cursor from the previous answer
curl "https://api.veragen.ai/v1/workflows?limit=50&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $VERAGEN_API_KEY"const all = [];
let cursor = null;
do {
const url = new URL("https://api.veragen.ai/v1/workflows");
url.searchParams.set("limit", "50");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}` },
});
const page = await res.json();
all.push(...page.data);
cursor = page.next_cursor;
} while (cursor);import os, requests
headers = {"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}"}
workflows, cursor = [], None
while True:
params = {"limit": 50, **({"cursor": cursor} if cursor else {})}
page = requests.get("https://api.veragen.ai/v1/workflows", params=params, headers=headers).json()
workflows += page["data"]
cursor = page["next_cursor"]
if not cursor:
breakA request that starts a workflow run must carry an Idempotency-Key header: a value you make up, unique to that one action. A UUID is ideal. It is what makes a retry safe. Creating a page or a contact takes the same header and works the same way, but there it is optional: without it, every request makes a new page (a contact is never made twice for one email address, key or no key). An update (PATCH) takes no key: sending the same change twice leaves the same result.
Idempotent-Replay: true. Nothing is started twice.422 idempotency_key_reused. Use a new key for a new action.409 idempotency_in_progress. Wait a few seconds and retry with the same key.409 idempotency_outcome_unknown and nothing is run again. List the workflow's runs (or the project's items, or contacts with that email) to see whether it happened; if it did not, send it again with a new key.Keys are remembered for 24 hours.
curl -X POST "https://api.veragen.ai/v1/workflows/{id}/runs" \
-H "Authorization: Bearer $VERAGEN_API_KEY" \
-H "Idempotency-Key: 6f1d2c3b-run-2026-10-03-1420" \
-H "Content-Type: application/json" \
-d '{}'const res = await fetch("https://api.veragen.ai/v1/workflows/{id}/runs", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}`, "Idempotency-Key": "6f1d2c3b-run-2026-10-03-1420", "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://api.veragen.ai/v1/workflows/{id}/runs",
headers={"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}", "Idempotency-Key": "6f1d2c3b-run-2026-10-03-1420"},
json={},
)
data = res.json(){
"id": "4b7e1c2d-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"workflow_id": "3f6c1d2e-8a4b-4f7e-9c21-5d0e7a1b2c3d",
"status": "queued",
"trigger": "api",
"created_at": "2026-10-03T14:20:00.000Z"
}Each key may make 120 requests a minute. Every answer tells you where you stand:
RateLimit-Limit: requests allowed this minute;RateLimit-Remaining: requests left this minute;RateLimit-Reset: seconds until the count starts again.Over the limit, the API answers 429 rate_limited with a Retry-After header: wait that many seconds, then carry on. Separately, more than 20 requests with a wrong key from one address in a minute are answered 429 too_many_failed_auth.
Starting runs has two more limits, per account: how many runs may be started through the API in a minute (429 run_rate_limited), and how many may be queued or running at once (429 concurrency_limited). Both answers carry Retry-After.
| Limit | Solo | Team | Business | Enterprise |
|---|---|---|---|---|
| Requests a minute, per key | 60 | 120 | 300 | 1,000 |
| Workflow runs started a minute, per account | 6 | 12 | 30 | By contract |
| Runs queued or running at once, per account | 2 | 5 | 10 | By contract |
Every account is on the Team limits today. The run limits count runs started through the API.
curl -i "https://api.veragen.ai/v1/workflows" \
-H "Authorization: Bearer $VERAGEN_API_KEY"
# RateLimit-Limit: 120
# RateLimit-Remaining: 119
# RateLimit-Reset: 42async function call(url) {
for (;;) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.VERAGEN_API_KEY}` },
});
if (res.status !== 429) return res;
const wait = Number(res.headers.get("Retry-After") || 1);
await new Promise((r) => setTimeout(r, wait * 1000));
}
}import os, time, requests
def call(url, **kw):
headers = {"Authorization": f"Bearer {os.environ['VERAGEN_API_KEY']}"}
while True:
res = requests.get(url, headers=headers, **kw)
if res.status_code != 429:
return res
time.sleep(int(res.headers.get("Retry-After", "1"))){
"error": {
"type": "rate_limit",
"code": "rate_limited",
"message": "Too many requests for this API key. Try again in 12 seconds.",
"request_id": "req_5f0c2a9e1b7d4c3a8e6f1d20"
}
}The major version is in the path: /v1. Within v1, changes are only additions: new endpoints, new optional parameters and new fields in responses. Write your code to ignore fields it does not know.
A change that could break existing code gets a new version, /v2, and v1 keeps working for at least 12 months after it is announced. Anything due to be retired is listed in the changelog and announced with a Sunset header.
Timestamps are ISO 8601 in UTC and end in Z. Ids are UUIDs.