Skip to content
Sign inGet your key

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:

terminal
npm i -g greenset
greenset login
greenset go yourstore.com

It 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

  1. POST /v1/stores Add a store by URL.
  2. POST /v1/concepts Draw up to six ads.
  3. POST /v1/drafts A dry run shows the checks and creates nothing.
  4. POST /v1/drafts With live: true it’s a draft that can’t spend.
  5. POST /v1/drafts/{id}/activate Type the budget back to start it.
  6. POST /v1/stop-all Pause 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.

ScopeWhat it allowsMoves money
readAccount, setup checks, stores, ads, drafts, campaigns and events. Changes nothing.No
storesAdd a store by URL and import its catalog.No
studioDraw ads from a store. Each ad uses one of the plan's images a day.No
draftsCreate 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
activateStart a draft spending. Needs the typed daily budget, and every cap and balance check still applies.Yes
stopPause every live campaign (typed STOP).No
autopilotRead guardrails and tighten them. Raising a limit needs you in the console.No
webhooksManage webhook endpoints and read deliveries.No

A workspace can hold 20 keys. A key that can move money lasts at most 90 days.

Limits

PlanRequests a minute, per keyAcross the workspaceImages a day
Free30606
The trial6012015
Premium120240No daily count (fair use 300)
Agency300600No 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.

CodeStatusMeaning
unauthorized401No key, a malformed key, or a key Greenset doesn't know.
key_revoked401The key was revoked. Create a new one under Developers.
key_expired401The key passed its expiry date.
key_owner_inactive401The person who made the key is no longer an active member of the workspace.
key_in_url401A key was sent in the URL. Keys go only in the Authorization header; rotate that key.
forbidden_origin403Browser requests from other sites are refused. Call the API from your server.
insufficient_scope403The key doesn't hold the scope this route needs (required_scope).
loosen_requires_console403The API can tighten guardrails, never loosen them. Raise a limit in the console.
invalid_request400A field is missing, has the wrong type, or is out of range (field names it).
invalid_json400The body isn't valid JSON.
invalid_cursor400The cursor isn't one this API returned.
confirm_required400A typed confirmation is missing.
confirm_mismatch400The typed daily budget doesn't equal the draft's daily budget.
destination_refused400The URL points somewhere Greenset won't fetch (private network, wrong scheme or port).
not_found404Nothing with that id in this workspace.
conflict409The request can't be applied in the current state (the message says why).
no_ad_account409The workspace has no ad account yet. Finish Setup.
account_not_ready409The ad account isn't ready for writes (Setup shows what's left).
activation_refused409A 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_unknown409The 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_gated409Refused 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_progress409A request with this Idempotency-Key is still running.
idempotency_key_reused422This Idempotency-Key was used for a different request in the last 24 hours.
unprocessable422The input is well-formed but can't be used (the message says why).
payload_too_large413Bodies are limited to 64 KB.
rate_limited429More 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_exceeded429Today's allowance for this kind of work is used. It resets at midnight UTC.
terms_update_required428Greenset'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_required402The 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_error500Greenset failed. Nothing was half-done that it didn't say.
upstream_error502The ad platform or the store didn't answer as expected. Safe to retry.
busy503The 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
FieldTypeDescription
workspace requiredobject
workspace.name requiredstring
ad_account requiredobject or null
ad_account.id requiredstringThe ad platform account (biz_…).
ad_account.name requiredstring
ad_account.linked_at requiredstring or null
setup requiredarray of objectThe 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 requiredstringaccount, adaccount, facebook, pixel, balance or draft.
setup[].status requiredone of pass, next, todo, fail
setup[].fact requiredstring
balance requiredobject or null
balance.available_usd requirednumber or null
balance.pending_usd requirednumber or null
balance.credits_usd requirednumber or null
payment_failed requiredobject or null
payment_failed.since requiredstring
payment_failed.issue requiredstring or null
caps requiredobject
caps.hard_daily_cap_usd requirednumber
caps.max_single_daily_usd requirednumber
caps.min_daily_budget_usd requirednumber
caps.overdelivery requirednumber
live_writes requiredobject
live_writes.enabled requiredboolean
live_writes.reasonstring
quotas requiredobjectToday'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 requiredobject
key.id requiredstringThis key's id.
key.name requiredstring
key.scopes requiredarray of one of read, stores, studio, drafts, activate, stop …
key.expires_at requiredstring or null
as_of requiredstring
curl
curl https://app.greenset.ai/v1/account \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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
FieldTypeDescription
data requiredarray of object
data[].id requiredstringStore connection id.
data[].platform requiredstring
data[].store_url requiredstring
data[].name requiredstring
data[].status requiredstring
data[].status_detail requiredstring or null
data[].connected_at requiredstring
data[].catalog requiredobject
data[].conversions requiredobject
data[].pixel requiredobject
has_more requiredboolean
next_cursor requiredstring or null
curl
curl https://app.greenset.ai/v1/stores \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
Idempotency-KeystringUp 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

FieldTypeDescription
url requiredstringThe store's address.
limitintegerProducts to import (default 250).

Answer 201

The store and what was imported.

16 fields
FieldTypeDescription
store requiredobject
store.id requiredstringStore connection id.
store.platform requiredstring
store.store_url requiredstring
store.name requiredstring
store.status requiredstring
store.status_detail requiredstring or null
store.connected_at requiredstring
store.catalog requiredobject
store.conversions requiredobject
store.pixel requiredobject
catalog requiredobject
catalog.count requiredinteger
catalog.total requiredinteger or null
catalog.source requiredstring
catalog.notes requiredarray 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
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"
  }'
JavaScript
// 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
# 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

ParameterTypeDescription
Idempotency-KeystringUp 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

FieldTypeDescription
url requiredstring
renderbooleanDefault true.
countintegerHow 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
FieldTypeDescription
batch_id requiredstringThis draw (bat_…).
brand requiredobject
brand.name requiredstring
brand.platform requiredstring or null
brand.colors requiredarray of string
concepts requiredarray of object
concepts[].id requiredstringConcept id.
concepts[].batch_id requiredstringThe draw it came from (bat_…).
concepts[].rank requiredinteger
concepts[].brand requiredstring or null
concepts[].source_url requiredstring or null
concepts[].hook requiredstring or null
concepts[].headline requiredstring or null
concepts[].subline requiredstring or null
concepts[].cta requiredstring or null
concepts[].call_to_action requiredstring or null
concepts[].score requirednumber or null
concepts[].layout requiredstring or null
concepts[].copy requiredobject
concepts[].product requiredobject or null
concepts[].images requiredobjectThe /v1 path of each format rendered (a concept drawn without render has none).
warnings requiredarray of string
images requiredobjectToday's image allowance (UTC day).
images.tier requiredone of free, trial, premium, agency
images.used requiredinteger
images.limit requiredinteger
images.left requiredinteger
images.unlimited requiredbooleanPremium 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
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"
  }'
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringConcept id (cpt_ and 12 hex characters).

Answer 200

The concept.

24 fields
FieldTypeDescription
id requiredstringConcept id.
batch_id requiredstringThe draw it came from (bat_…).
rank requiredinteger
brand requiredstring or null
source_url requiredstring or null
hook requiredstring or null
headline requiredstring or null
subline requiredstring or null
cta requiredstring or null
call_to_action requiredstring or null
score requirednumber or null
layout requiredstring or null
copy requiredobject
copy.headlines requiredarray of string
copy.primary_texts requiredarray of string
copy.descriptions requiredarray of string
product requiredobject or null
product.title requiredstring or null
product.price requiredstring or null
product.url requiredstring or null
images requiredobjectThe /v1 path of each format rendered (a concept drawn without render has none).
images.squarestring
images.verticalstring
images.horizontalstring

Can refuse with

404 not_found

curl
curl https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringConcept id (cpt_ and 12 hex characters).
format requiredone of square, vertical, horizontalsquare (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

404 not_found

curl
curl https://app.greenset.ai/v1/concepts/cpt_1a2b3c4d5e6f/images/square \
  -H "Authorization: Bearer $GREENSET_KEY" \
  -o greenset-square.png
JavaScript
// 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
# 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

ParameterTypeDescription
limitintegerRows per page, 1–100 (default 20).
cursorstringnext_cursor from the previous page.

Answer 200

A page of drafts.

16 fields
FieldTypeDescription
data requiredarray of object
data[].id requiredstringDraft id.
data[].concept_id requiredstringThe concept it was drafted from.
data[].mode requiredone of dry_run, live
data[].status requiredstring
data[].title requiredstring or null
data[].daily_budget_usd requirednumber
data[].countries requiredarray of string
data[].destination_url requiredstring
data[].campaign_id requiredstring or null
data[].error requiredstring or null
data[].created_at requiredstring or null
data[].updated_at requiredstring or null
data[].placements requiredobject
has_more requiredboolean
next_cursor requiredstring or null

Can refuse with

400 invalid_cursor

curl
curl https://app.greenset.ai/v1/drafts \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
Idempotency-KeystringUp 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

FieldTypeDescription
concept_id requiredstring
destination_url requiredstringWhere the ad sends people (https).
daily_budget_usd requirednumberUS 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.
countriesarray of stringISO 3166-1 alpha-2 (default US).
livebooleanCreate it on the ad platform (as a draft that can't spend). Default false.
confirm_budget_usdnumberRequired with live: true. Must equal daily_budget_usd.
placementsobjectWhere 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 requiredone of automatic, manual
placements.facebookstring"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.instagramstring"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
FieldTypeDescription
id requiredstringDraft id.
mode requiredone of dry_run, live
gated_reason requiredstring or null
planned_requests requiredarray of object
planned_requests[].step requiredstring
planned_requests[].method requiredone of GET, POST, PUT, PATCH
planned_requests[].path requiredstringAd platform API path.
planned_requests[].headersobject
planned_requests[].bodyanyThe JSON body Greenset sends.
planned_requests[].notestring
checks requiredarray of object
checks[].id requiredone of whop_requirements, ads_agreement, payment_method, facebook_page, pixel, ad_balance …
checks[].status requiredone of pass, fail, unknown, review
checks[].detail requiredstring
removed_params requiredarray of object
removed_params[].param requiredstring
removed_params[].value requiredstring
removed_params[].reason requiredstring
result requiredobject or nullLive creates: the ids the ad platform gave back (ad_campaign_id, ad_group_id, …).
placements requiredobject
placements.modeone of automatic, manual
placements.presetone of automatic, facebook_instagram, facebook, instagram, other
placements.facebookstring or null
placements.instagramstring or null
placements.otherarray of object
placements.countinteger or null
placements.ofinteger
placements.labelstring
placements.legacyboolean
placements_check requiredobject or nullLive creates only: where the ad platform holds the ad as showing.
placements_check.ok requiredboolean
placements_check.in_whop requiredobject or null
placements_check.sentence requiredstring

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
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
  }'
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringDraft id (drf_ and 12 hex characters).

Answer 200

The draft.

29 fields
FieldTypeDescription
id requiredstringDraft id.
concept_id requiredstringThe concept it was drafted from.
mode requiredone of dry_run, live
status requiredstring
title requiredstring or null
daily_budget_usd requirednumber
countries requiredarray of string
destination_url requiredstring
campaign_id requiredstring or null
error requiredstring or null
created_at requiredstring or null
updated_at requiredstring or null
placements requiredobject
placements.modeone of automatic, manual
placements.presetone of automatic, facebook_instagram, facebook, instagram, other
placements.facebookstring or null
placements.instagramstring or null
placements.otherarray of object
placements.countinteger or null
placements.ofinteger
placements.labelstring
placements.legacyboolean
planned_requests requiredarray of object
planned_requests[].step requiredstring
planned_requests[].method requiredone of GET, POST, PUT, PATCH
planned_requests[].path requiredstringAd platform API path.
planned_requests[].headersobject
planned_requests[].bodyanyThe JSON body Greenset sends.
planned_requests[].notestring

Can refuse with

404 not_found

curl
curl https://app.greenset.ai/v1/drafts/drf_0a1b2c3d4e5f \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringDraft id (drf_ and 12 hex characters).
Idempotency-KeystringUp 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

FieldTypeDescription
confirm_budget_usd requirednumberThe draft's daily budget, typed back.
acknowledge_reviewbooleanSend 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
FieldTypeDescription
activated requiredone of True
campaign_id requiredstring
verdict requiredstringWhat the guardrails said of this start: needs_approval (the typed budget was the approval) or, with Launch autonomy on Auto, allow_and_notify.
cap requiredobject or nullThe hard daily cap as activation read it.
cap.day requiredstringYYYY-MM-DD on the ad account's clock.
cap.time_zone requiredstring
cap.spent_today_usd requirednumber
cap.committed_usd requirednumber
cap.after_usd requirednumber
cap.worst_case_usd requirednumber
cap.cap_usd requirednumber
stopped_again requiredobject or nullStop 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 requiredbooleanThe ad platform confirmed the pause.
stopped_again.unknown requiredbooleanThe ad platform's answer to the pause was lost: it may still be spending.
stopped_again.reason requiredstringWhat 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
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
  }'
JavaScript
// 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
# 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

ParameterTypeDescription
stats_fromstringYYYY-MM-DD on the account's clock (default today).
stats_tostringYYYY-MM-DD (default stats_from).
limitintegerRows per page, 1–100 (default 20).
cursorstringnext_cursor from the previous page.

Answer 200

A page of campaigns.

31 fields
FieldTypeDescription
data requiredarray of object
data[].id requiredstring or null
data[].title requiredstring or null
data[].status requiredstring or null
data[].delivery_status requiredstring or null
data[].platform requiredstring or null
data[].objective requiredstring or null
data[].budget_amount requirednumber or null
data[].budget_type requiredstring or null
data[].spend requirednumber or null
data[].spend_currency requiredstring or null
data[].impressions requirednumber or null
data[].clicks requirednumber or null
data[].results requirednumber or null
data[].result_event requiredstring or null
data[].cost_per_result requirednumber or null
data[].purchases requirednumber or null
data[].purchase_value requirednumber or null
data[].return_on_ad_spend requirednumber or null
data[].created_at requiredstring or null
data[].updated_at requiredstring or null
has_more requiredboolean
next_cursor requiredstring or null
window requiredobject
window.from requiredstring
window.to requiredstring
window.time_zone requiredstring
window.time_zone_note requiredstring or null
window.attribution_model requiredstring
roas_label requiredstring
as_of requiredstring

Can refuse with

400 invalid_cursor409 no_ad_account502 upstream_error

curl
curl https://app.greenset.ai/v1/campaigns \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
limitintegerRows per page, 1–100 (default 20).
cursorstringnext_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
FieldTypeDescription
data requiredarray of object
data[].id requiredstringEvent id.
data[].type requiredstringThe ad platform's event name as recorded (deposit.succeeded, …).
data[].resource_id requiredstring or null
data[].summary requiredstring
data[].created_at requiredstring
has_more requiredboolean
next_cursor requiredstring or null

Can refuse with

400 invalid_cursor

curl
curl https://app.greenset.ai/v1/events \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
Idempotency-KeystringUp 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

FieldTypeDescription
confirm requiredone of STOP

Answer 200

What was paused (or would be).

15 fields
FieldTypeDescription
dry_run requiredboolean
gated_reason requiredstring or null
account requiredstringThe ad account, shortened.
live_found requiredinteger
paused_count requiredinteger
failed_count requiredinteger
results requiredarray of object
results[].campaign_id requiredstring
results[].title requiredstring
results[].status requiredstring
results[].paused requiredboolean
results[].would_pause requiredboolean
results[].whop_status requiredinteger or null
results[].error requiredstring or null
note requiredstring

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
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"
  }'
JavaScript
// 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
# 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
FieldTypeDescription
autonomy requiredobject
autonomy.read requiredone of Notify, Suggest, Confirm, Auto
autonomy.pause requiredone of Notify, Suggest, Confirm, Auto
autonomy.budget requiredone of Notify, Suggest, Confirm, Auto
autonomy.creative requiredone of Notify, Suggest, Confirm, Auto
autonomy.launch requiredone of Notify, Suggest, Confirm, Auto
autonomy.billing requiredone of Notify, Suggest, Confirm, Auto
autonomy.destination requiredone of Notify, Suggest, Confirm, Auto
hard_daily_cap_usd requirednumber
roas_floor requirednumber
cpa_target_usd requirednumber or null
curl
curl https://app.greenset.ai/v1/guardrails \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
Idempotency-KeystringUp 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

FieldTypeDescription
hard_daily_cap_usdnumberUS 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_floornumberA 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_usdnumberUS 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.
autonomyobjectPer kind of action: Notify | Suggest | Confirm | Auto (billing and destination can never be Auto).
autonomy.readone of Notify, Suggest, Confirm, Auto
autonomy.pauseone of Notify, Suggest, Confirm, Auto
autonomy.budgetone of Notify, Suggest, Confirm, Auto
autonomy.creativeone of Notify, Suggest, Confirm, Auto
autonomy.launchone of Notify, Suggest, Confirm, Auto
autonomy.billingone of Notify, Suggest, Confirm, Auto
autonomy.destinationone of Notify, Suggest, Confirm, Auto

Answer 200

The saved guardrails, every field.

11 fields
FieldTypeDescription
autonomy requiredobject
autonomy.read requiredone of Notify, Suggest, Confirm, Auto
autonomy.pause requiredone of Notify, Suggest, Confirm, Auto
autonomy.budget requiredone of Notify, Suggest, Confirm, Auto
autonomy.creative requiredone of Notify, Suggest, Confirm, Auto
autonomy.launch requiredone of Notify, Suggest, Confirm, Auto
autonomy.billing requiredone of Notify, Suggest, Confirm, Auto
autonomy.destination requiredone of Notify, Suggest, Confirm, Auto
hard_daily_cap_usd requirednumber
roas_floor requirednumber
cpa_target_usd requirednumber or null

Can refuse with

400 invalid_json403 loosen_requires_console409 idempotency_in_progress413 payload_too_large422 idempotency_key_reused428 terms_update_required

curl
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"
    }
  }'
JavaScript
// 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
# 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
FieldTypeDescription
data requiredarray of object
data[].id requiredstringEndpoint id.
data[].url requiredstring
data[].description requiredstring or null
data[].events requiredarray of one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low …
data[].enabled requiredboolean
data[].disabled_reason requiredstring or null
data[].consecutive_failures requiredinteger
data[].disabled_at requiredstring or null
data[].created_at requiredstring
data[].last_success_at requiredstring or null
data[].last_failure_at requiredstring or null
has_more requiredboolean
next_cursor requiredstring or null
curl
curl https://app.greenset.ai/v1/webhooks \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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

ParameterTypeDescription
Idempotency-KeystringUp 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

FieldTypeDescription
url requiredstring
events requiredarray of one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low …
descriptionstring

Answer 201

The endpoint and its secret (shown once).

14 fields
FieldTypeDescription
endpoint requiredobject
endpoint.id requiredstringEndpoint id.
endpoint.url requiredstring
endpoint.description requiredstring or null
endpoint.events requiredarray of one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low …
endpoint.enabled requiredboolean
endpoint.disabled_reason requiredstring or null
endpoint.consecutive_failures requiredinteger
endpoint.disabled_at requiredstring or null
endpoint.created_at requiredstring
endpoint.last_success_at requiredstring or null
endpoint.last_failure_at requiredstring or null
secret requiredstring or nullThe endpoint's whsec_ signing secret, in this answer only. null in an idempotent replay.
secret_notestringOnly in an idempotent replay.

Can refuse with

400 invalid_json409 conflict409 idempotency_in_progress413 payload_too_large422 idempotency_key_reused428 terms_update_required

curl
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"
    ]
  }'
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringEndpoint id (we_ and 16 characters).
Idempotency-KeystringUp 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
FieldTypeDescription
deleted requiredone of True
id requiredstringThe endpoint deleted.

Can refuse with

404 not_found409 idempotency_in_progress422 idempotency_key_reused428 terms_update_required

curl
curl -X DELETE https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V \
  -H "Authorization: Bearer $GREENSET_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringEndpoint id (we_ and 16 characters).
Idempotency-KeystringUp 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
FieldTypeDescription
delivery_id requiredstringDelivery id.
event_id requiredstringEvent id (webhook-id).
ok requiredboolean
status requiredinteger or null
error requiredstring or null
ms requiredinteger or null

Can refuse with

404 not_found409 idempotency_in_progress422 idempotency_key_reused428 terms_update_required

curl
curl -X POST https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/test \
  -H "Authorization: Bearer $GREENSET_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
// 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
# 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

ParameterTypeDescription
id requiredstringEndpoint id (we_ and 16 characters).
limitintegerRows per page, 1–100 (default 20).
cursorstringnext_cursor from the previous page.

Answer 200

A page of deliveries, newest first.

13 fields
FieldTypeDescription
data requiredarray of object
data[].id requiredstringDelivery id.
data[].event_id requiredstringThe event's id: the webhook-id header, the same on every retry.
data[].type requiredstring
data[].status requiredone of pending, sending, succeeded, failed, cancelled
data[].attempts requiredinteger
data[].last_status requiredinteger or null
data[].last_error requiredstring or null
data[].next_attempt_at requiredstring or null
data[].created_at requiredstring
data[].tries requiredarray of objectThe latest 10 attempts, newest first.
has_more requiredboolean
next_cursor requiredstring or null

Can refuse with

400 invalid_cursor404 not_found

curl
curl https://app.greenset.ai/v1/webhooks/we_Rk7Lq2Xm9Pz4Tn6V/deliveries \
  -H "Authorization: Bearer $GREENSET_KEY"
JavaScript
// 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
# 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
FieldTypeDescription
openapi requiredstring
info requiredobject
paths requiredobject
curl
curl https://app.greenset.ai/v1/openapi.json
JavaScript
// 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
# 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": {}
}

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.

EventWhen
deposit.receivedFunds reached your ad balance.
campaign.submittedA draft was activated and sent to Meta for review.
campaign.liveThe ad platform reports a campaign delivering.
ad.rejectedMeta rejected an ad; the ad platform's first reason is included.
payment.failedThe ad platform couldn't collect ad spend; delivery is paused until it's fixed.
balance.lowAbout three days or less of ads left at today's budgets.
cap.reachedToday's spend reached your hard daily cap; Greenset paused live campaigns.
autopilot.proposalAutopilot has a change waiting for your approval.
automation.firedA rule you set fired.
competitor.changedA competitor you track changed an offer, a price or its ads.
pixel.stoppedThe ad platform stopped seeing its pixel on one of your stores.
account.restrictedThe ad platform reports your ad account can't launch ads.
webhook.testSent 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

terminal
claude mcp add --transport http greenset --scope user https://app.greenset.ai/mcp --header "Authorization: Bearer $GREENSET_KEY"

Codex

terminal
codex mcp add greenset --url https://app.greenset.ai/mcp --bearer-token-env-var GREENSET_KEY

Cursor

.cursor/mcp.json
{
  "mcpServers": {
    "greenset": {
      "url": "https://app.greenset.ai/mcp",
      "headers": {
        "Authorization": "Bearer ${env:GREENSET_KEY}"
      }
    }
  }
}

VS Code

.vscode/mcp.json
{
  "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.