Docs.
Draw ads from a store, draft them, activate them and watch them, from your own code.
In a terminal: the CLI. From an agent: the MCP server. From code: the API, all of it below.
Start
Make a key under Developers in the console. Then:
npm i -g greenset
greenset login
greenset go yourstore.comIt asks only what it can’t read, saves the images, opens a preview in your browser and checks everything without spending. Nothing starts until you type the daily budget back. The ad then goes to Meta’s review.
Once, in the console: your ad account, Facebook page, pixel and ad balance. greenset whoami or GET /v1/account shows what’s left.
The same path over REST
- POST
/v1/storesAdd a store by URL. - POST
/v1/conceptsDraw up to six ads. - POST
/v1/draftsA dry run shows the checks and creates nothing. - POST
/v1/draftsWithlive: trueit’s a draft that can’t spend. - POST
/v1/drafts/{id}/activateType the budget back to start it. - POST
/v1/stop-allPause everything.
Keys and scopes
Authenticate with Authorization: Bearer gsk_live_…. Keys are made under Developers in Greenset and shown once. Cookies and keys in the URL are refused; there is no CORS, so call the API from a server.
| Scope | What it allows | Moves money |
|---|---|---|
read | Account, setup checks, stores, ads, drafts, campaigns and events. Changes nothing. | No |
stores | Add a store by URL and import its catalog. | No |
studio | Draw ads from a store. Each ad uses one of the plan's images a day. | No |
drafts | Create drafts: a dry run by default; with live: true a draft on the ad platform, while live writes are on. A draft can't spend. | No |
activate | Start a draft spending. Needs the typed daily budget, and every cap and balance check still applies. | Yes |
stop | Pause every live campaign (typed STOP). | No |
autopilot | Read guardrails and tighten them. Raising a limit needs you in the console. | No |
webhooks | Manage webhook endpoints and read deliveries. | No |
A workspace can hold 20 keys. A key that can move money lasts at most 90 days.
Limits
| Plan | Requests a minute, per key | Across the workspace | Images a day |
|---|---|---|---|
| Free | 30 | 60 | 6 |
| The trial | 60 | 120 | 15 |
| Premium | 120 | 240 | No daily count (fair use 300) |
| Agency | 300 | 600 | No daily count (fair use 1500) |
Store reads: 120 a day. Request bodies: up to 64 KB. An Idempotency-Key is remembered for 24 hours. Answers carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 carries Retry-After.
Money
A draft never spends. Activation needs the activate scope, the daily budget typed back as confirm_budget_usd, and passes the same hard daily cap (210% worst-day rule: Meta can deliver up to 2.1× a daily budget in one day), ad-balance and live-writes checks as the console. The API can lower limits; raising them needs a person in the console.
Errors
Errors are always {"error": {"code", "message", "request_id"}}. Every answer carries an X-Request-Id header; quote it when you write to Greenset. Lists page with limit and cursor and answer has_more and next_cursor.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No key, a malformed key, or a key Greenset doesn't know. |
key_revoked | 401 | The key was revoked. Create a new one under Developers. |
key_expired | 401 | The key passed its expiry date. |
key_owner_inactive | 401 | The person who made the key is no longer an active member of the workspace. |
key_in_url | 401 | A key was sent in the URL. Keys go only in the Authorization header; rotate that key. |
forbidden_origin | 403 | Browser requests from other sites are refused. Call the API from your server. |
insufficient_scope | 403 | The key doesn't hold the scope this route needs (required_scope). |
loosen_requires_console | 403 | The API can tighten guardrails, never loosen them. Raise a limit in the console. |
invalid_request | 400 | A field is missing, has the wrong type, or is out of range (field names it). |
invalid_json | 400 | The body isn't valid JSON. |
invalid_cursor | 400 | The cursor isn't one this API returned. |
confirm_required | 400 | A typed confirmation is missing. |
confirm_mismatch | 400 | The typed daily budget doesn't equal the draft's daily budget. |
destination_refused | 400 | The URL points somewhere Greenset won't fetch (private network, wrong scheme or port). |
not_found | 404 | Nothing with that id in this workspace. |
conflict | 409 | The request can't be applied in the current state (the message says why). |
no_ad_account | 409 | The workspace has no ad account yet. Finish Setup. |
account_not_ready | 409 | The ad account isn't ready for writes (Setup shows what's left). |
activation_refused | 409 | A spend check refused activation (hard daily cap, ad balance, live writes, Stop everything since it began) or the ad platform refused the start. Nothing was started. |
activation_unknown | 409 | The ad platform's answer to the start was lost and couldn't be proven either way: the campaign may have started. Greenset counts it against the hard daily cap; check the campaign in Launch and activate again only if the ad platform still shows a draft. |
store_gated | 409 | Refused for one of two reasons: the store's platform can't be connected by URL, or Greenset has seen the store on an ad account that was suspended or restricted. The message says which. |
idempotency_in_progress | 409 | A request with this Idempotency-Key is still running. |
idempotency_key_reused | 422 | This Idempotency-Key was used for a different request in the last 24 hours. |
unprocessable | 422 | The input is well-formed but can't be used (the message says why). |
payload_too_large | 413 | Bodies are limited to 64 KB. |
rate_limited | 429 | More requests a minute than the plan allows on this key, or across the workspace's keys (by plan, per key: Free 30, the trial 60, Premium 120, Agency 300 requests a minute; twice that across a workspace's keys). Wait Retry-After seconds. |
quota_exceeded | 429 | Today's allowance for this kind of work is used. It resets at midnight UTC. |
terms_update_required | 428 | Greenset's Terms changed since the person who made this key agreed. They accept the new version in Greenset; until then the key reads but changes nothing. |
upgrade_required | 402 | The plan's allowance is used (Free: 6 images a day; the trial: 15), or the feature is part of Premium. The message says which; Plan in Greenset upgrades. |
internal_error | 500 | Greenset failed. Nothing was half-done that it didn't say. |
upstream_error | 502 | The ad platform or the store didn't answer as expected. Safe to retry. |
busy | 503 | The studio is busy rendering. Retry in a minute. |
Versioning: every route is under /v1. No /v1 route has been removed. Routes, fields and error codes that change are listed in the changelog at https://app.greenset.ai/docs#changelog; the wording of a fact or a message can change without a row.
Account
GET /v1/account
Setup lamps, balance, caps and this key
scope: read
The six setup checks Setup shows, with times in UTC. Also the ad balance read from the ad platform (cached up to a minute), the hard daily cap and what it allows, whether live writes are on, today's allowances, and which scopes this key holds.
Answer 200
The workspace.
32 fields
| Field | Type | Description |
|---|---|---|
workspace required | object | |
workspace.name required | string | |
ad_account required | object or null | |
ad_account.id required | string | The ad platform account (biz_…). |
ad_account.name required | string | |
ad_account.linked_at required | string or null | |
setup required | array of object | The six setup checks Setup shows, with times in UTC. The first check's fact names the sign-in email of the person who made this key. |
setup[].id required | string | account, adaccount, facebook, pixel, balance or draft. |
setup[].status required | one of pass, next, todo, fail | |
setup[].fact required | string | |
balance required | object or null | |
balance.available_usd required | number or null | |
balance.pending_usd required | number or null | |
balance.credits_usd required | number or null | |
payment_failed required | object or null | |
payment_failed.since required | string | |
payment_failed.issue required | string or null | |
caps required | object | |
caps.hard_daily_cap_usd required | number | |
caps.max_single_daily_usd required | number | |
caps.min_daily_budget_usd required | number | |
caps.overdelivery required | number | |
live_writes required | object | |
live_writes.enabled required | boolean | |
live_writes.reason | string | |
quotas required | object | Today's allowances by kind (UTC day): scrape is store reads, photos is Studio photo sets, copilot is questions to Greeni, elements is money screens opened in Setup. Growth runs and Autopilot cycles count against daily limits of their own (100 each unless set otherwise), not listed here. Images drawn are not here: they are counted by plan, and POST /v1/concepts answers today's count as images. |
key required | object | |
key.id required | string | This key's id. |
key.name required | string | |
key.scopes required | array of one of read, stores, studio, drafts, activate, stop … | |
key.expires_at required | string or null | |
as_of required | string |
curl https://app.greenset.ai/v1/account \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/account", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/account",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"workspace": {
"name": "Mossgate Coffee"
},
"ad_account": {
"id": "biz_LiveChild001",
"name": "Mossgate Coffee",
"linked_at": "2026-10-01T09:14:02.000Z"
},
"setup": [
{
"id": "account",
"status": "pass",
"fact": "owner@mossgatecoffee.example · confirmed 2026-10-01 09:12 UTC"
},
{
"id": "adaccount",
"status": "pass",
"fact": "biz_••d001 · pays from your ad balance · since 2026-10-01 09:14 UTC"
},
{
"id": "facebook",
"status": "pass",
"fact": "\"Mossgate Coffee\" · sacc••Qp1Z · can advertise"
},
{
"id": "pixel",
"status": "pass",
"fact": "The ad platform confirmed the pixel on mossgatecoffee.example · 2026-10-02 10:30 UTC"
},
{
"id": "balance",
"status": "pass",
"fact": "$412.50 available"
},
{
"id": "draft",
"status": "next",
"fact": "Draw ads for what you sell in Studio"
}
],
"balance": {
"available_usd": 412.5,
"pending_usd": 0,
"credits_usd": 0
},
"payment_failed": null,
"caps": {
"hard_daily_cap_usd": 500,
"max_single_daily_usd": 238.09,
"min_daily_budget_usd": 5,
"overdelivery": 2.1
},
"live_writes": {
"enabled": true
},
"quotas": {
"scrape": {
"used": 0,
"limit": 120
},
"photos": {
"used": 0,
"limit": 40
},
"copilot": {
"used": 0,
"limit": 200
},
"elements": {
"used": 0,
"limit": 120
}
},
"key": {
"id": "key_Ab12Cd34Ef56",
"name": "Zapier",
"scopes": [
"read",
"stores",
"studio",
"drafts",
"activate",
"stop"
],
"expires_at": "2026-11-04T13:29:30.000Z"
},
"as_of": "2026-10-05T13:31:07.000Z"
}Stores
GET /v1/stores
Connected stores
scope: read
Every store connected to the workspace, with its catalog, conversions and pixel state. Credentials never appear.
Answer 200
Stores (one page, at most 100: a workspace has few).
13 fields
| Field | Type | Description |
|---|---|---|
data required | array of object | |
data[].id required | string | Store connection id. |
data[].platform required | string | |
data[].store_url required | string | |
data[].name required | string | |
data[].status required | string | |
data[].status_detail required | string or null | |
data[].connected_at required | string | |
data[].catalog required | object | |
data[].conversions required | object | |
data[].pixel required | object | |
has_more required | boolean | |
next_cursor required | string or null |
curl https://app.greenset.ai/v1/stores \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/stores", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/stores",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"data": [
{
"id": "conn_9Xk2Lm4Pq7Rs",
"platform": "shopify",
"store_url": "https://mossgatecoffee.example",
"name": "Mossgate Coffee",
"status": "connected",
"status_detail": null,
"connected_at": "2026-10-01T09:20:44.000Z",
"catalog": {
"count": 38,
"total": 38,
"synced_at": "2026-10-01T09:20:47.000Z",
"source": "shopify-json"
},
"conversions": {
"mode": "pixel",
"state": "dry_run",
"last_event_at": null
},
"pixel": {
"installed": true,
"verified_at": "2026-10-02T10:30:05.000Z",
"method": null
}
}
],
"has_more": false,
"next_cursor": null
}POST /v1/stores
Add a store by URL
scope: stores
Detects the platform, connects the store by its public pages and imports up to limit products. Uses one store read from today's allowance. A new store counts toward the plan's stores (Free and Premium 1, Agency its store count); past it, 402 upgrade_required. Reconnecting a store already here doesn't count. Connecting with admin credentials (Shopify, WordPress) stays in the console, because it needs the store owner's consent screen. A store Greenset has seen on an ad account that was suspended or restricted is refused (store_gated), as in the console; when Greenset can't confirm that with the ad platform, nothing is linked (upstream_error, safe to retry).
Path and query
| Parameter | Type | Description |
|---|---|---|
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
url required | string | The store's address. |
limit | integer | Products to import (default 250). |
Answer 201
The store and what was imported.
16 fields
| Field | Type | Description |
|---|---|---|
store required | object | |
store.id required | string | Store connection id. |
store.platform required | string | |
store.store_url required | string | |
store.name required | string | |
store.status required | string | |
store.status_detail required | string or null | |
store.connected_at required | string | |
store.catalog required | object | |
store.conversions required | object | |
store.pixel required | object | |
catalog required | object | |
catalog.count required | integer | |
catalog.total required | integer or null | |
catalog.source required | string | |
catalog.notes required | array of string |
Can refuse with
400 destination_refused400 invalid_json402 upgrade_required409 idempotency_in_progress409 store_gated413 payload_too_large422 idempotency_key_reused428 terms_update_required429 quota_exceeded502 upstream_error
curl -X POST https://app.greenset.ai/v1/stores \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://mossgatecoffee.example"
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/stores", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"url": "https://mossgatecoffee.example"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"url": "https://mossgatecoffee.example",
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/stores",
method="POST",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"store": {
"id": "conn_9Xk2Lm4Pq7Rs",
"platform": "shopify",
"store_url": "https://mossgatecoffee.example",
"name": "Mossgate Coffee",
"status": "connected",
"status_detail": null,
"connected_at": "2026-10-01T09:20:44.000Z",
"catalog": {
"count": 38,
"total": 38,
"synced_at": "2026-10-01T09:20:47.000Z",
"source": "shopify-json"
},
"conversions": {
"mode": "pixel",
"state": "dry_run",
"last_event_at": null
},
"pixel": {
"installed": true,
"verified_at": "2026-10-02T10:30:05.000Z",
"method": null
}
},
"catalog": {
"count": 38,
"total": 38,
"source": "shopify-json",
"notes": []
}
}Studio
POST /v1/concepts
Draw up to six ads from a store
scope: studio
Reads the store, cuts out its products and renders up to six ad concepts (count, default 6) on the ad platform's three formats (square, vertical, horizontal). Takes 20–60 s. Each concept is one image from the plan's daily allowance (Free 6, the trial 15, Premium and Agency fair use); fewer are made when fewer are left, and any that fail to render are given back (render: false uses a store read instead and returns copy without images). Console and API renders share the same two render slots.
Path and query
| Parameter | Type | Description |
|---|---|---|
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
url required | string | |
render | boolean | Default true. |
count | integer | How many concepts to render (default 6); each is one image from the daily allowance. |
Answer 201
The concepts drawn (up to six), best first, and today's image allowance after this draw.
28 fields
| Field | Type | Description |
|---|---|---|
batch_id required | string | This draw (bat_…). |
brand required | object | |
brand.name required | string | |
brand.platform required | string or null | |
brand.colors required | array of string | |
concepts required | array of object | |
concepts[].id required | string | Concept id. |
concepts[].batch_id required | string | The draw it came from (bat_…). |
concepts[].rank required | integer | |
concepts[].brand required | string or null | |
concepts[].source_url required | string or null | |
concepts[].hook required | string or null | |
concepts[].headline required | string or null | |
concepts[].subline required | string or null | |
concepts[].cta required | string or null | |
concepts[].call_to_action required | string or null | |
concepts[].score required | number or null | |
concepts[].layout required | string or null | |
concepts[].copy required | object | |
concepts[].product required | object or null | |
concepts[].images required | object | The /v1 path of each format rendered (a concept drawn without render has none). |
warnings required | array of string | |
images required | object | Today's image allowance (UTC day). |
images.tier required | one of free, trial, premium, agency | |
images.used required | integer | |
images.limit required | integer | |
images.left required | integer | |
images.unlimited required | boolean | Premium and Agency: no daily count; limit is then the fair-use ceiling. |
Can refuse with
400 destination_refused400 invalid_json402 upgrade_required409 idempotency_in_progress413 payload_too_large422 idempotency_key_reused422 unprocessable428 terms_update_required429 quota_exceeded502 upstream_error503 busy
curl -X POST https://app.greenset.ai/v1/concepts \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://mossgatecoffee.example"
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/concepts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"url": "https://mossgatecoffee.example"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"url": "https://mossgatecoffee.example",
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/concepts",
method="POST",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"batch_id": "bat_4e8a1c2b9d70",
"brand": {
"name": "Mossgate Coffee",
"platform": "shopify",
"colors": [
"#2B1D14",
"#E8D9C5"
]
},
"concepts": [
{
"id": "cpt_1a2b3c4d5e6f",
"batch_id": "bat_4e8a1c2b9d70",
"rank": 1,
"brand": "Mossgate Coffee",
"source_url": "https://mossgatecoffee.example",
"hook": "desire",
"headline": "Roasted Tuesday, at your door Friday",
"subline": "Small-batch beans, shipped weekly",
"cta": "Shop now",
"call_to_action": "shop_now",
"score": 8,
"layout": "hero",
"copy": {
"headlines": [
"Roasted Tuesday, at your door Friday"
],
"primary_texts": [
"Small-batch coffee from our roastery, shipped the week it's roasted."
],
"descriptions": [
"House blend, $18"
]
},
"product": {
"title": "House blend",
"price": "$18.00",
"url": "https://mossgatecoffee.example/products/house-blend"
},
"images": {
"square": "/v1/concepts/cpt_1a2b3c4d5e6f/images/square",
"vertical": "/v1/concepts/cpt_1a2b3c4d5e6f/images/vertical",
"horizontal": "/v1/concepts/cpt_1a2b3c4d5e6f/images/horizontal"
}
}
],
"warnings": [],
"images": {
"tier": "free",
"used": 6,
"limit": 6,
"left": 0,
"unlimited": false
}
}GET /v1/concepts/{id}
One concept
scope: read
Copy, product and image links for one concept drawn in this workspace.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Concept id (cpt_ and 12 hex characters). |
Answer 200
The concept.
24 fields
| Field | Type | Description |
|---|---|---|
id required | string | Concept id. |
batch_id required | string | The draw it came from (bat_…). |
rank required | integer | |
brand required | string or null | |
source_url required | string or null | |
hook required | string or null | |
headline required | string or null | |
subline required | string or null | |
cta required | string or null | |
call_to_action required | string or null | |
score required | number or null | |
layout required | string or null | |
copy required | object | |
copy.headlines required | array of string | |
copy.primary_texts required | array of string | |
copy.descriptions required | array of string | |
product required | object or null | |
product.title required | string or null | |
product.price required | string or null | |
product.url required | string or null | |
images required | object | The /v1 path of each format rendered (a concept drawn without render has none). |
images.square | string | |
images.vertical | string | |
images.horizontal | string |
Can refuse with
curl https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"id": "cpt_1a2b3c4d5e6f",
"batch_id": "bat_4e8a1c2b9d70",
"rank": 1,
"brand": "Mossgate Coffee",
"source_url": "https://mossgatecoffee.example",
"hook": "desire",
"headline": "Roasted Tuesday, at your door Friday",
"subline": "Small-batch beans, shipped weekly",
"cta": "Shop now",
"call_to_action": "shop_now",
"score": 8,
"layout": "hero",
"copy": {
"headlines": [
"Roasted Tuesday, at your door Friday"
],
"primary_texts": [
"Small-batch coffee from our roastery, shipped the week it's roasted."
],
"descriptions": [
"House blend, $18"
]
},
"product": {
"title": "House blend",
"price": "$18.00",
"url": "https://mossgatecoffee.example/products/house-blend"
},
"images": {
"square": "/v1/concepts/cpt_1a2b3c4d5e6f/images/square",
"vertical": "/v1/concepts/cpt_1a2b3c4d5e6f/images/vertical",
"horizontal": "/v1/concepts/cpt_1a2b3c4d5e6f/images/horizontal"
}
}GET /v1/concepts/{id}/images/{format}
A concept's rendered image
scope: read
The PNG Greenset rendered for one format. These are the exact bytes a live draft uploads to the ad platform.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Concept id (cpt_ and 12 hex characters). |
format required | one of square, vertical, horizontal | square (1:1), vertical (9:16) or horizontal (1.91:1). |
Answer 200
The PNG: square 1080×1080, vertical 1080×1920, horizontal 1200×628 pixels.
Can refuse with
curl https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f/images/square \
-H "Authorization: Bearer $GREENSET_KEY" \
-o greenset-square.png// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { writeFile } from "node:fs/promises";
const res = await fetch("https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f/images/square", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
await writeFile("greenset-square.png", Buffer.from(await res.arrayBuffer()));# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f/images/square",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res, open("greenset-square.png", "wb") as f:
f.write(res.read())
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")Drafts
GET /v1/drafts
Drafts, newest first
scope: read
Dry runs and live drafts. A live draft exists on the ad platform as a draft campaign and cannot spend until it is activated.
Path and query
| Parameter | Type | Description |
|---|---|---|
limit | integer | Rows per page, 1–100 (default 20). |
cursor | string | next_cursor from the previous page. |
Answer 200
A page of drafts.
16 fields
| Field | Type | Description |
|---|---|---|
data required | array of object | |
data[].id required | string | Draft id. |
data[].concept_id required | string | The concept it was drafted from. |
data[].mode required | one of dry_run, live | |
data[].status required | string | |
data[].title required | string or null | |
data[].daily_budget_usd required | number | |
data[].countries required | array of string | |
data[].destination_url required | string | |
data[].campaign_id required | string or null | |
data[].error required | string or null | |
data[].created_at required | string or null | |
data[].updated_at required | string or null | |
data[].placements required | object | |
has_more required | boolean | |
next_cursor required | string or null |
Can refuse with
curl https://app.greenset.ai/v1/drafts \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/drafts", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/drafts",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"data": [
{
"id": "drf_0a1b2c3d4e5f",
"concept_id": "cpt_1a2b3c4d5e6f",
"mode": "live",
"status": "created",
"title": "House blend · autumn",
"daily_budget_usd": 25,
"countries": [
"US"
],
"destination_url": "https://mossgatecoffee.example/products/house-blend",
"campaign_id": "adcamp_8Hq2LmN4pR7sTv",
"error": null,
"created_at": "2026-10-05T13:40:00.000Z",
"updated_at": "2026-10-05T13:40:09.000Z",
"placements": {
"mode": "automatic",
"preset": "automatic",
"facebook": null,
"instagram": null,
"other": [],
"count": null,
"of": 19,
"label": "Automatic"
}
}
],
"has_more": false,
"next_cursor": null
}POST /v1/drafts
Draft an ad from a concept
scope: drafts
Checks the budget against Greenset's minimum and your hard daily cap, cleans the ad platform's reserved parameters off the link, and plans the requests. A dry run (the default) creates nothing on the ad platform and sends none of the planned requests. Where your workspace has an ad account, it reads that account from the ad platform and, unless the ad links to one of the ad platform's own pages, asks the ad platform whether the pixel is on that page. live: true with confirm_budget_usd equal to daily_budget_usd creates the campaign on the ad platform as a draft (0 spend) when live writes are on; when they're off, you get a dry run and gated_reason says why.
Path and query
| Parameter | Type | Description |
|---|---|---|
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
concept_id required | string | |
destination_url required | string | Where the ad sends people (https). |
daily_budget_usd required | number | US dollars a day: the ad's daily budget. Meta can spend up to 210% of it in one day, and that worst day has to fit under your hard daily cap. |
countries | array of string | ISO 3166-1 alpha-2 (default US). |
live | boolean | Create it on the ad platform (as a draft that can't spend). Default false. |
confirm_budget_usd | number | Required with live: true. Must equal daily_budget_usd. |
placements | object | Where the ad shows. Leave it out for Automatic (Meta chooses, across Facebook, Instagram, Messenger, Audience Network, Threads and WhatsApp). manual takes Facebook and Instagram places only; at least one place, and a platform sent with a list needs at least one place in it. Meta's rules on which places go together are checked before anything reaches the ad platform: On Facebook, Marketplace, Search results, Profile feed and Notifications can show only alongside Facebook Feed (Meta's rule). On Facebook, Stories can show only alongside Facebook Feed or Instagram Stories (Meta's rule). On Facebook, In-stream videos can show only alongside Facebook Feed (Meta's rule). |
placements.mode required | one of automatic, manual | |
placements.facebook | string | "all", or a list of Facebook places: feed (Feed), right_hand_column (Right column), marketplace (Marketplace), search (Search results), profile_feed (Profile feed), notification (Notifications), story (Stories), instream_video (In-stream videos), facebook_reels (Reels), facebook_reels_overlay (Ads on Reels), biz_disco_feed (Business Explore). |
placements.instagram | string | "all", or a list of Instagram places: stream (Feed), story (Stories), explore (Explore), explore_home (Explore home), reels (Reels), profile_feed (Profile feed), profile_reels (Profile reels), ig_search (Search results). |
Answer 201
The draft, its checks and the requests Greenset plans to send to the ad platform.
33 fields
| Field | Type | Description |
|---|---|---|
id required | string | Draft id. |
mode required | one of dry_run, live | |
gated_reason required | string or null | |
planned_requests required | array of object | |
planned_requests[].step required | string | |
planned_requests[].method required | one of GET, POST, PUT, PATCH | |
planned_requests[].path required | string | Ad platform API path. |
planned_requests[].headers | object | |
planned_requests[].body | any | The JSON body Greenset sends. |
planned_requests[].note | string | |
checks required | array of object | |
checks[].id required | one of whop_requirements, ads_agreement, payment_method, facebook_page, pixel, ad_balance … | |
checks[].status required | one of pass, fail, unknown, review | |
checks[].detail required | string | |
removed_params required | array of object | |
removed_params[].param required | string | |
removed_params[].value required | string | |
removed_params[].reason required | string | |
result required | object or null | Live creates: the ids the ad platform gave back (ad_campaign_id, ad_group_id, …). |
placements required | object | |
placements.mode | one of automatic, manual | |
placements.preset | one of automatic, facebook_instagram, facebook, instagram, other | |
placements.facebook | string or null | |
placements.instagram | string or null | |
placements.other | array of object | |
placements.count | integer or null | |
placements.of | integer | |
placements.label | string | |
placements.legacy | boolean | |
placements_check required | object or null | Live creates only: where the ad platform holds the ad as showing. |
placements_check.ok required | boolean | |
placements_check.in_whop required | object or null | |
placements_check.sentence required | string |
Can refuse with
400 confirm_mismatch400 confirm_required400 destination_refused400 invalid_json404 not_found409 account_not_ready409 conflict409 idempotency_in_progress409 no_ad_account413 payload_too_large422 idempotency_key_reused422 unprocessable428 terms_update_required502 upstream_error
curl -X POST https://app.greenset.ai/v1/drafts \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"concept_id": "cpt_1a2b3c4d5e6f",
"destination_url": "https://mossgatecoffee.example/products/house-blend",
"daily_budget_usd": 25
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/drafts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"concept_id": "cpt_1a2b3c4d5e6f",
"destination_url": "https://mossgatecoffee.example/products/house-blend",
"daily_budget_usd": 25
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"concept_id": "cpt_1a2b3c4d5e6f",
"destination_url": "https://mossgatecoffee.example/products/house-blend",
"daily_budget_usd": 25,
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/drafts",
method="POST",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"id": "drf_6f5e4d3c2b1a",
"mode": "dry_run",
"gated_reason": null,
"planned_requests": [
{
"step": "create_campaign",
"method": "POST",
"path": "/ad_campaigns",
"body": {
"title": "House blend · autumn"
}
}
],
"checks": [
{
"id": "whop_limits",
"status": "pass",
"detail": "Withon the ad platform's account limits."
}
],
"removed_params": [],
"result": null,
"placements": {
"mode": "automatic",
"preset": "automatic",
"facebook": null,
"instagram": null,
"other": [],
"count": null,
"of": 19,
"label": "Automatic"
},
"placements_check": null
}GET /v1/drafts/{id}
One draft, with its planned requests
scope: read
Includes planned_requests: every request Greenset sends (or would send) to the ad platform for this draft, with idempotency keys.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Draft id (drf_ and 12 hex characters). |
Answer 200
The draft.
29 fields
| Field | Type | Description |
|---|---|---|
id required | string | Draft id. |
concept_id required | string | The concept it was drafted from. |
mode required | one of dry_run, live | |
status required | string | |
title required | string or null | |
daily_budget_usd required | number | |
countries required | array of string | |
destination_url required | string | |
campaign_id required | string or null | |
error required | string or null | |
created_at required | string or null | |
updated_at required | string or null | |
placements required | object | |
placements.mode | one of automatic, manual | |
placements.preset | one of automatic, facebook_instagram, facebook, instagram, other | |
placements.facebook | string or null | |
placements.instagram | string or null | |
placements.other | array of object | |
placements.count | integer or null | |
placements.of | integer | |
placements.label | string | |
placements.legacy | boolean | |
planned_requests required | array of object | |
planned_requests[].step required | string | |
planned_requests[].method required | one of GET, POST, PUT, PATCH | |
planned_requests[].path required | string | Ad platform API path. |
planned_requests[].headers | object | |
planned_requests[].body | any | The JSON body Greenset sends. |
planned_requests[].note | string |
Can refuse with
curl https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"id": "drf_0a1b2c3d4e5f",
"concept_id": "cpt_1a2b3c4d5e6f",
"mode": "live",
"status": "created",
"title": "House blend · autumn",
"daily_budget_usd": 25,
"countries": [
"US"
],
"destination_url": "https://mossgatecoffee.example/products/house-blend",
"campaign_id": "adcamp_8Hq2LmN4pR7sTv",
"error": null,
"created_at": "2026-10-05T13:40:00.000Z",
"updated_at": "2026-10-05T13:40:09.000Z",
"placements": {
"mode": "automatic",
"preset": "automatic",
"facebook": null,
"instagram": null,
"other": [],
"count": null,
"of": 19,
"label": "Automatic"
},
"planned_requests": [
{
"step": "create_campaign",
"method": "POST",
"path": "/ad_campaigns",
"body": {
"title": "House blend · autumn"
}
}
]
}POST /v1/drafts/{id}/activate
Start a live draft spending
scope: activatemoves money
Moves money. Type the draft's daily budget back as confirm_budget_usd. Greenset then reads today's spend and every live budget from the ad platform, refuses if this budget's worst day (210% of it, Meta's ceiling with budget sharing) would take you over your hard daily cap, refuses if your ad balance can't cover one day (and no card is saved), refuses while a failed payment is unresolved or live writes are off, and only then asks the ad platform to start the campaign. Any read that fails refuses. Activations in a workspace run one at a time, behind any Live ads change or Autopilot cycle that can raise spend: one waits at most 20 seconds, then is refused with nothing started, and once its turn comes the workspace's ad account is checked again.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Draft id (drf_ and 12 hex characters). |
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
confirm_budget_usd required | number | The draft's daily budget, typed back. |
acknowledge_review | boolean | Send true once you've read the words the policy screen flagged, when its check says review (an ambiguous term such as "supplement"). Without it such a draft is refused with activation_refused. The acknowledgement is recorded on the draft against the key's owner. |
Answer 200
Activated: The ad platform has the campaign and it is in Meta's review. stopped_again says when Stop everything or the hard daily cap's pause ran while it was being started (Greenset paused it again right after).
15 fields
| Field | Type | Description |
|---|---|---|
activated required | one of True | |
campaign_id required | string | |
verdict required | string | What the guardrails said of this start: needs_approval (the typed budget was the approval) or, with Launch autonomy on Auto, allow_and_notify. |
cap required | object or null | The hard daily cap as activation read it. |
cap.day required | string | YYYY-MM-DD on the ad account's clock. |
cap.time_zone required | string | |
cap.spent_today_usd required | number | |
cap.committed_usd required | number | |
cap.after_usd required | number | |
cap.worst_case_usd required | number | |
cap.cap_usd required | number | |
stopped_again required | object or null | Stop everything or the hard daily cap's pause ran while this campaign was being started, so Greenset paused it again right after. Null when nothing did. |
stopped_again.paused required | boolean | The ad platform confirmed the pause. |
stopped_again.unknown required | boolean | The ad platform's answer to the pause was lost: it may still be spending. |
stopped_again.reason required | string | What happened, in words: tell the person this. |
Can refuse with
400 confirm_mismatch400 confirm_required400 invalid_json404 not_found409 account_not_ready409 activation_refused409 activation_unknown409 conflict409 idempotency_in_progress409 no_ad_account413 payload_too_large422 idempotency_key_reused428 terms_update_required502 upstream_error
curl -X POST https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f/activate \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"confirm_budget_usd": 25
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f/activate", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"confirm_budget_usd": 25
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"confirm_budget_usd": 25,
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f/activate",
method="POST",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"activated": true,
"campaign_id": "adcamp_8Hq2LmN4pR7sTv",
"verdict": "needs_approval",
"cap": {
"day": "2026-10-05",
"time_zone": "America/New_York",
"spent_today_usd": 0,
"committed_usd": 0,
"after_usd": 25,
"worst_case_usd": 52.5,
"cap_usd": 500
},
"stopped_again": null
}Campaigns
GET /v1/campaigns
Campaigns with the ad platform's numbers
scope: read
Read live from the ad platform for the window you ask for, on your ad account's own clock, last-touch. A number the ad platform doesn't report stays null (never 0). ROAS counts only payments the ad platform processes. Every page carries as_of.
Path and query
| Parameter | Type | Description |
|---|---|---|
stats_from | string | YYYY-MM-DD on the account's clock (default today). |
stats_to | string | YYYY-MM-DD (default stats_from). |
limit | integer | Rows per page, 1–100 (default 20). |
cursor | string | next_cursor from the previous page. |
Answer 200
A page of campaigns.
31 fields
| Field | Type | Description |
|---|---|---|
data required | array of object | |
data[].id required | string or null | |
data[].title required | string or null | |
data[].status required | string or null | |
data[].delivery_status required | string or null | |
data[].platform required | string or null | |
data[].objective required | string or null | |
data[].budget_amount required | number or null | |
data[].budget_type required | string or null | |
data[].spend required | number or null | |
data[].spend_currency required | string or null | |
data[].impressions required | number or null | |
data[].clicks required | number or null | |
data[].results required | number or null | |
data[].result_event required | string or null | |
data[].cost_per_result required | number or null | |
data[].purchases required | number or null | |
data[].purchase_value required | number or null | |
data[].return_on_ad_spend required | number or null | |
data[].created_at required | string or null | |
data[].updated_at required | string or null | |
has_more required | boolean | |
next_cursor required | string or null | |
window required | object | |
window.from required | string | |
window.to required | string | |
window.time_zone required | string | |
window.time_zone_note required | string or null | |
window.attribution_model required | string | |
roas_label required | string | |
as_of required | string |
Can refuse with
curl https://app.greenset.ai/v1/campaigns \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/campaigns", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/campaigns",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"data": [
{
"id": "adcamp_8Hq2LmN4pR7sTv",
"title": "House blend · autumn",
"status": "active",
"delivery_status": "active",
"platform": "meta",
"objective": "sales",
"budget_amount": 25,
"budget_type": "daily",
"spend": 18.2,
"spend_currency": "usd",
"impressions": 2140,
"clicks": 61,
"results": null,
"result_event": null,
"cost_per_result": null,
"purchases": null,
"purchase_value": null,
"return_on_ad_spend": 0,
"created_at": "2026-10-05T13:40:00.000Z",
"updated_at": "2026-10-05T14:00:00.000Z"
}
],
"has_more": false,
"next_cursor": null,
"window": {
"from": "2026-10-05",
"to": "2026-10-05",
"time_zone": "America/New_York",
"time_zone_note": "Day boundaries use America/New_York, your ad account's reporting time zone.",
"attribution_model": "last_touch"
},
"roas_label": "Checkout ROAS, last-touch",
"as_of": "2026-10-05T14:02:11.000Z"
}GET /v1/events
What the ad platform told Greenset, newest first
scope: read
Deposits, payment failures, campaigns delivering, ads rejected, balance and pixel alerts, as recorded for this workspace.
Path and query
| Parameter | Type | Description |
|---|---|---|
limit | integer | Rows per page, 1–100 (default 20). |
cursor | string | next_cursor from the previous page. |
Answer 200
A page of events. type is the ad platform's event name as Greenset recorded it: a deposit.succeeded is listed here as deposit.succeeded and sent to webhook endpoints as deposit.received.
8 fields
| Field | Type | Description |
|---|---|---|
data required | array of object | |
data[].id required | string | Event id. |
data[].type required | string | The ad platform's event name as recorded (deposit.succeeded, …). |
data[].resource_id required | string or null | |
data[].summary required | string | |
data[].created_at required | string | |
has_more required | boolean | |
next_cursor required | string or null |
Can refuse with
curl https://app.greenset.ai/v1/events \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/events", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/events",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"data": [
{
"id": "pev_Qm3Xr8Tz1Kp5",
"type": "deposit.succeeded",
"resource_id": null,
"summary": "Deposit $500.00 reached the ad balance",
"created_at": "2026-10-05T13:58:02.000Z"
}
],
"has_more": false,
"next_cursor": null
}Safety
POST /v1/stop-all
Pause every live campaign
scope: stop
Send {"confirm": "STOP"}. Pauses every live campaign of this workspace's own ad account through the ad platform's pause action and logs each one. Works whatever state Setup is in. While live writes are off, it pauses nothing and returns what it would pause; it reads your campaigns and account settings from the ad platform. The ad platform's Ads Terms: a paused campaign may still run for up to 24 hours after the ad platform receives the pause.
Path and query
| Parameter | Type | Description |
|---|---|---|
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
confirm required | one of STOP |
Answer 200
What was paused (or would be).
15 fields
| Field | Type | Description |
|---|---|---|
dry_run required | boolean | |
gated_reason required | string or null | |
account required | string | The ad account, shortened. |
live_found required | integer | |
paused_count required | integer | |
failed_count required | integer | |
results required | array of object | |
results[].campaign_id required | string | |
results[].title required | string | |
results[].status required | string | |
results[].paused required | boolean | |
results[].would_pause required | boolean | |
results[].whop_status required | integer or null | |
results[].error required | string or null | |
note required | string |
Can refuse with
400 confirm_required400 invalid_json409 conflict409 idempotency_in_progress409 no_ad_account413 payload_too_large422 idempotency_key_reused428 terms_update_required502 upstream_error
curl -X POST https://app.greenset.ai/v1/stop-all \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"confirm": "STOP"
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/stop-all", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"confirm": "STOP"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"confirm": "STOP",
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/stop-all",
method="POST",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"dry_run": false,
"gated_reason": null,
"account": "biz_••d001",
"live_found": 1,
"paused_count": 1,
"failed_count": 0,
"results": [
{
"campaign_id": "adcamp_8Hq2LmN4pR7sTv",
"title": "House blend · autumn",
"status": "active",
"paused": true,
"would_pause": false,
"whop_status": 200,
"error": null
}
],
"note": "The ad platform's Ads Terms say a paused campaign may still run for up to 24 hours after the ad platform receives the pause, and charges before the pause still apply."
}Autopilot
GET /v1/guardrails
Spend cap, ROAS floor, CPA target and autonomy
scope: autopilot
The guardrails every spend decision is checked against: the hard daily cap, Autopilot's ROAS floor and CPA target, and the autonomy level per kind of action.
Answer 200
Guardrails.
11 fields
| Field | Type | Description |
|---|---|---|
autonomy required | object | |
autonomy.read required | one of Notify, Suggest, Confirm, Auto | |
autonomy.pause required | one of Notify, Suggest, Confirm, Auto | |
autonomy.budget required | one of Notify, Suggest, Confirm, Auto | |
autonomy.creative required | one of Notify, Suggest, Confirm, Auto | |
autonomy.launch required | one of Notify, Suggest, Confirm, Auto | |
autonomy.billing required | one of Notify, Suggest, Confirm, Auto | |
autonomy.destination required | one of Notify, Suggest, Confirm, Auto | |
hard_daily_cap_usd required | number | |
roas_floor required | number | |
cpa_target_usd required | number or null |
curl https://app.greenset.ai/v1/guardrails \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/guardrails", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/guardrails",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"autonomy": {
"read": "Auto",
"pause": "Confirm",
"budget": "Suggest",
"creative": "Suggest",
"launch": "Confirm",
"billing": "Notify",
"destination": "Notify"
},
"hard_daily_cap_usd": 500,
"roas_floor": 3,
"cpa_target_usd": null
}PUT /v1/guardrails
Tighten guardrails
scope: autopilot
Send only the fields you change. The API can only make guardrails stricter: lower the hard daily cap, raise the ROAS floor, lower an existing CPA target, or move an action to less autonomy (Auto → Confirm → Suggest → Notify). Anything that loosens is refused with loosen_requires_console and nothing is saved; a person makes that change in the console.
Path and query
| Parameter | Type | Description |
|---|---|---|
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
hard_daily_cap_usd | number | US dollars a day, on the ad account's clock. A budget is refused when the live daily budgets with it could spend more than this in one day at Meta's 210% ceiling, and Greenset pauses every live campaign after today's spend reaches it. |
roas_floor | number | A multiple of ad spend, not a percent: 3 means 3×. For ad groups measured on purchases: one whose return on ad spend from platform checkouts is under it gets no budget raise from Autopilot or a rule. |
cpa_target_usd | number | US dollars: what one result should cost. Autopilot counts an ad group's spend in multiples of it when it decides whether to pause one that has no results. |
autonomy | object | Per kind of action: Notify | Suggest | Confirm | Auto (billing and destination can never be Auto). |
autonomy.read | one of Notify, Suggest, Confirm, Auto | |
autonomy.pause | one of Notify, Suggest, Confirm, Auto | |
autonomy.budget | one of Notify, Suggest, Confirm, Auto | |
autonomy.creative | one of Notify, Suggest, Confirm, Auto | |
autonomy.launch | one of Notify, Suggest, Confirm, Auto | |
autonomy.billing | one of Notify, Suggest, Confirm, Auto | |
autonomy.destination | one of Notify, Suggest, Confirm, Auto |
Answer 200
The saved guardrails, every field.
11 fields
| Field | Type | Description |
|---|---|---|
autonomy required | object | |
autonomy.read required | one of Notify, Suggest, Confirm, Auto | |
autonomy.pause required | one of Notify, Suggest, Confirm, Auto | |
autonomy.budget required | one of Notify, Suggest, Confirm, Auto | |
autonomy.creative required | one of Notify, Suggest, Confirm, Auto | |
autonomy.launch required | one of Notify, Suggest, Confirm, Auto | |
autonomy.billing required | one of Notify, Suggest, Confirm, Auto | |
autonomy.destination required | one of Notify, Suggest, Confirm, Auto | |
hard_daily_cap_usd required | number | |
roas_floor required | number | |
cpa_target_usd required | number or null |
Can refuse with
400 invalid_json403 loosen_requires_console409 idempotency_in_progress413 payload_too_large422 idempotency_key_reused428 terms_update_required
curl -X PUT https://app.greenset.ai/v1/guardrails \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"hard_daily_cap_usd": 250,
"autonomy": {
"budget": "Notify"
}
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/guardrails", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"hard_daily_cap_usd": 250,
"autonomy": {
"budget": "Notify"
}
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"hard_daily_cap_usd": 250,
"autonomy": {
"budget": "Notify",
},
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/guardrails",
method="PUT",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"autonomy": {
"read": "Auto",
"pause": "Confirm",
"budget": "Notify",
"creative": "Suggest",
"launch": "Confirm",
"billing": "Notify",
"destination": "Notify"
},
"hard_daily_cap_usd": 250,
"roas_floor": 3,
"cpa_target_usd": null
}Webhooks
GET /v1/webhooks
Webhook endpoints
scope: webhooks
Every endpoint of the workspace. Secrets are never returned after creation.
Answer 200
Endpoints (one page: a workspace has at most 10).
14 fields
| Field | Type | Description |
|---|---|---|
data required | array of object | |
data[].id required | string | Endpoint id. |
data[].url required | string | |
data[].description required | string or null | |
data[].events required | array of one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low … | |
data[].enabled required | boolean | |
data[].disabled_reason required | string or null | |
data[].consecutive_failures required | integer | |
data[].disabled_at required | string or null | |
data[].created_at required | string | |
data[].last_success_at required | string or null | |
data[].last_failure_at required | string or null | |
has_more required | boolean | |
next_cursor required | string or null |
curl https://app.greenset.ai/v1/webhooks \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/webhooks", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/webhooks",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"data": [
{
"id": "we_Rk7Lq2Xm9Pz4Tn6V",
"url": "https://hooks.example.com/greenset",
"description": "Slack relay",
"events": [
"campaign.live",
"payment.failed",
"balance.low"
],
"enabled": true,
"disabled_reason": null,
"disabled_at": null,
"consecutive_failures": 0,
"created_at": "2026-10-03T08:00:00.000Z",
"last_success_at": "2026-10-05T13:41:02.000Z",
"last_failure_at": null
}
],
"has_more": false,
"next_cursor": null
}POST /v1/webhooks
Add a webhook endpoint
scope: webhooks
An https URL on a public host (port 443, no IP addresses, nothing that resolves to a private network) and the events it wants. The answer carries the endpoint's whsec_ signing secret once; store it. Up to 10 endpoints per workspace. The workspace's owners get an email whenever an endpoint is added.
Path and query
| Parameter | Type | Description |
|---|---|---|
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Body
| Field | Type | Description |
|---|---|---|
url required | string | |
events required | array of one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low … | |
description | string |
Answer 201
The endpoint and its secret (shown once).
14 fields
| Field | Type | Description |
|---|---|---|
endpoint required | object | |
endpoint.id required | string | Endpoint id. |
endpoint.url required | string | |
endpoint.description required | string or null | |
endpoint.events required | array of one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low … | |
endpoint.enabled required | boolean | |
endpoint.disabled_reason required | string or null | |
endpoint.consecutive_failures required | integer | |
endpoint.disabled_at required | string or null | |
endpoint.created_at required | string | |
endpoint.last_success_at required | string or null | |
endpoint.last_failure_at required | string or null | |
secret required | string or null | The endpoint's whsec_ signing secret, in this answer only. null in an idempotent replay. |
secret_note | string | Only in an idempotent replay. |
Can refuse with
400 invalid_json409 conflict409 idempotency_in_progress413 payload_too_large422 idempotency_key_reused428 terms_update_required
curl -X POST https://app.greenset.ai/v1/webhooks \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://hooks.example.com/greenset",
"events": [
"campaign.live",
"payment.failed",
"balance.low"
]
}'// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
"url": "https://hooks.example.com/greenset",
"events": [
"campaign.live",
"payment.failed",
"balance.low"
]
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
body = {
"url": "https://hooks.example.com/greenset",
"events": ["campaign.live", "payment.failed", "balance.low"],
}
req = urllib.request.Request(
"https://app.greenset.ai/v1/webhooks",
method="POST",
data=json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"endpoint": {
"id": "we_Rk7Lq2Xm9Pz4Tn6V",
"url": "https://hooks.example.com/greenset",
"description": "Slack relay",
"events": [
"campaign.live",
"payment.failed",
"balance.low"
],
"enabled": true,
"disabled_reason": null,
"disabled_at": null,
"consecutive_failures": 0,
"created_at": "2026-10-03T08:00:00.000Z",
"last_success_at": null,
"last_failure_at": null
},
"secret": "whsec_(shown once)"
}DELETE /v1/webhooks/{id}
Delete a webhook endpoint
scope: webhooks
Stops all deliveries to it at once. Its delivery log goes with it.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Endpoint id (we_ and 16 characters). |
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Answer 200
Deleted.
2 fields
| Field | Type | Description |
|---|---|---|
deleted required | one of True | |
id required | string | The endpoint deleted. |
Can refuse with
404 not_found409 idempotency_in_progress422 idempotency_key_reused428 terms_update_required
curl -X DELETE https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Idempotency-Key: $(uuidgen)"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Idempotency-Key": randomUUID(),
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V",
method="DELETE",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"deleted": true,
"id": "we_Rk7Lq2Xm9Pz4Tn6V"
}POST /v1/webhooks/{id}/test
Send a test event now
scope: webhooks
One signed webhook.test delivery to this endpoint, right now, whether it is on or off. Not retried and not counted toward switch-off. Up to 10 a minute per workspace.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Endpoint id (we_ and 16 characters). |
Idempotency-Key | string | Up to 255 printable characters. The first answer is stored for 24 hours; a retry with the same key and the same request gets that answer back (header Idempotent-Replayed: true) instead of running twice. |
Answer 200
What the endpoint answered.
6 fields
| Field | Type | Description |
|---|---|---|
delivery_id required | string | Delivery id. |
event_id required | string | Event id (webhook-id). |
ok required | boolean | |
status required | integer or null | |
error required | string or null | |
ms required | integer or null |
Can refuse with
404 not_found409 idempotency_in_progress422 idempotency_key_reused428 terms_update_required
curl -X POST https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/test \
-H "Authorization: Bearer $GREENSET_KEY" \
-H "Idempotency-Key: $(uuidgen)"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
import { randomUUID } from "node:crypto";
const res = await fetch("https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
"Idempotency-Key": randomUUID(),
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request, uuid
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/test",
method="POST",
data=b"",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"delivery_id": "dlv_Hn4Wq8Zr2Lm6Pk1S",
"event_id": "msg_Vb5Nc9Xe3Rt7Yu2I",
"ok": true,
"status": 204,
"error": null,
"ms": 182
}GET /v1/webhooks/{id}/deliveries
An endpoint's delivery log
scope: webhooks
Each delivery with its status, attempts and every attempt's HTTP status code.
Path and query
| Parameter | Type | Description |
|---|---|---|
id required | string | Endpoint id (we_ and 16 characters). |
limit | integer | Rows per page, 1–100 (default 20). |
cursor | string | next_cursor from the previous page. |
Answer 200
A page of deliveries, newest first.
13 fields
| Field | Type | Description |
|---|---|---|
data required | array of object | |
data[].id required | string | Delivery id. |
data[].event_id required | string | The event's id: the webhook-id header, the same on every retry. |
data[].type required | string | |
data[].status required | one of pending, sending, succeeded, failed, cancelled | |
data[].attempts required | integer | |
data[].last_status required | integer or null | |
data[].last_error required | string or null | |
data[].next_attempt_at required | string or null | |
data[].created_at required | string | |
data[].tries required | array of object | The latest 10 attempts, newest first. |
has_more required | boolean | |
next_cursor required | string or null |
Can refuse with
curl https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/deliveries \
-H "Authorization: Bearer $GREENSET_KEY"// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/deliveries", {
headers: {
Authorization: `Bearer ${process.env.GREENSET_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/deliveries",
headers={
"Authorization": "Bearer " + os.environ["GREENSET_KEY"],
},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"data": [
{
"id": "dlv_Hn4Wq8Zr2Lm6Pk1S",
"event_id": "msg_Vb5Nc9Xe3Rt7Yu2I",
"type": "campaign.live",
"status": "succeeded",
"attempts": 1,
"last_status": 200,
"last_error": null,
"next_attempt_at": null,
"created_at": "2026-10-05T13:41:01.000Z",
"tries": [
{
"attempt": 1,
"status": 200,
"error": null,
"ms": 140,
"at": "2026-10-05T13:41:02.000Z"
}
]
}
],
"has_more": false,
"next_cursor": null
}Reference
GET /v1/openapi.json
This API's OpenAPI 3.1 document
no key needed
Public and cacheable. The Developers screen renders its reference from this document; generate a client from it if you like.
Answer 200
The OpenAPI document (the example is abridged).
3 fields
| Field | Type | Description |
|---|---|---|
openapi required | string | |
info required | object | |
paths required | object |
curl https://app.greenset.ai/v1/openapi.json// Node 18 or later. Save as greenset.mjs, then: node greenset.mjs
const res = await fetch("https://app.greenset.ai/v1/openapi.json", {
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
console.log(data);# Python 3, standard library only.
import json, os, urllib.request
from urllib.error import HTTPError
req = urllib.request.Request(
"https://app.greenset.ai/v1/openapi.json",
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except HTTPError as e:
err = json.load(e)["error"]
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
print(data)Example answer
{
"openapi": "3.1.0",
"info": {
"title": "Greenset API",
"version": "1.0.0"
},
"paths": {}
}No endpoint matches that.
Webhook events
Greenset POSTs each event to every enabled endpoint subscribed to it. Headers: webhook-id (same on every retry), webhook-timestamp (Unix seconds), webhook-signature ("v1," + base64 HMAC-SHA256 over "{id}.{timestamp}.{body}" with the base64-decoded part of your whsec_ secret). Answer any 2xx within 10 s. Up to 8 attempts: the first right away, then retries after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, each wait counted from the attempt before, so the last is about 20.6 hours after the first; nothing is sent once 24 hours have passed. 20 failed attempts in a row switch the endpoint off, and the workspace's owners get one email.
| Event | When |
|---|---|
deposit.received | Funds reached your ad balance. |
campaign.submitted | A draft was activated and sent to Meta for review. |
campaign.live | The ad platform reports a campaign delivering. |
ad.rejected | Meta rejected an ad; the ad platform's first reason is included. |
payment.failed | The ad platform couldn't collect ad spend; delivery is paused until it's fixed. |
balance.low | About three days or less of ads left at today's budgets. |
cap.reached | Today's spend reached your hard daily cap; Greenset paused live campaigns. |
autopilot.proposal | Autopilot has a change waiting for your approval. |
automation.fired | A rule you set fired. |
competitor.changed | A competitor you track changed an offer, a price or its ads. |
pixel.stopped | The ad platform stopped seeing its pixel on one of your stores. |
account.restricted | The ad platform reports your ad account can't launch ads. |
webhook.test | Sent only by “Send test event”. |
MCP
The same API as MCP tools for AI terminals and editors, with the same keys, scopes and limits. The CLI sets it up for you: greenset mcp add claude, cursor, vscode or codex.
Claude Code
claude mcp add --transport http greenset --scope user https://app.greenset.ai/mcp --header "Authorization: Bearer $GREENSET_KEY"Codex
codex mcp add greenset --url https://app.greenset.ai/mcp --bearer-token-env-var GREENSET_KEYCursor
{
"mcpServers": {
"greenset": {
"url": "https://app.greenset.ai/mcp",
"headers": {
"Authorization": "Bearer ${env:GREENSET_KEY}"
}
}
}
}VS Code
{
"inputs": [
{
"type": "promptString",
"id": "greenset-key",
"description": "Greenset API key",
"password": true
}
],
"servers": {
"greenset": {
"type": "http",
"url": "https://app.greenset.ai/mcp",
"headers": {
"Authorization": "Bearer ${input:greenset-key}"
}
}
}
}For agents: /llms.txt is the index and /v1/openapi.json the whole API.