GREENSET

Greenset · API

Greenset API

Version 1.0.0generated from /v1/openapi.json21 operations

Base URLhttps://app.greenset.ai/v1
Contents · 19 sections
  1. 01Quickstart
  2. 02Keys and scopes
  3. 03Account
  4. 04Stores
  5. 05Studio
  6. 06Drafts
  7. 07Campaigns
  8. 08Safety
  9. 09Autopilot
  10. 10Webhooks
  11. 11Reference
  12. 12Errors
  13. 13Limits
  14. 14Pages
  15. 15Retrying safely
  16. 16Webhook events
  17. 17MCP and AI tools
  18. 18Versions and support
  19. 19Changelog

01Quickstart#

Six calls, from a new key to a stopped campaign.

  1. Step 01: Make a key, then make a call

    Make a key under Developers, then Keys, in Greenset, and set export GREENSET_KEY=<your key>. This call needs the Read scope and changes nothing.

    GET /v1/account

    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)
    Answer · 200
    JSON
    {
      "workspace": {
        "name": "Northfield Coffee"
      },
      "ad_account": {
        "id": "biz_LiveChild001",
        "name": "Northfield Coffee",
        "linked_at": "2026-10-01T09:14:02.000Z"
      },
      "setup": [
        {
          "id": "account",
          "status": "pass",
          "fact": "owner@northfieldcoffee.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": "\"Northfield Coffee\" · sacc••Qp1Z · can advertise"
        },
        {
          "id": "pixel",
          "status": "pass",
          "fact": "Whop confirmed the pixel on northfieldcoffee.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"
    }

    GET /v1/account: every field

  2. Step 02: Connect your store

    Greenset reads the store's public pages and imports its products. It uses one of today's 120 store reads. Needs the Stores scope.

    POST /v1/stores

    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://northfieldcoffee.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://northfieldcoffee.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://northfieldcoffee.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)
    Answer · 201
    JSON
    {
      "store": {
        "id": "conn_9Xk2Lm4Pq7Rs",
        "platform": "shopify",
        "store_url": "https://northfieldcoffee.example",
        "name": "Northfield 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": []
      }
    }

    POST /v1/stores: every field

  3. Step 03: Draw ads

    Each ad drawn uses one image from today's image allowance (Free 6, the trial 15; Premium and Agency have no daily count). It takes 20 to 60 seconds. Needs the Studio scope.

    POST /v1/concepts

    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://northfieldcoffee.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://northfieldcoffee.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://northfieldcoffee.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)
    Answer · 201
    JSON
    {
      "batch_id": "bat_4e8a1c2b9d70",
      "brand": {
        "name": "Northfield Coffee",
        "platform": "shopify",
        "colors": [
          "#2B1D14",
          "#E8D9C5"
        ]
      },
      "concepts": [
        {
          "id": "cpt_1a2b3c4d5e6f",
          "batch_id": "bat_4e8a1c2b9d70",
          "rank": 1,
          "brand": "Northfield Coffee",
          "source_url": "https://northfieldcoffee.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://northfieldcoffee.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
      }
    }

    POST /v1/concepts: every field

  4. Step 04: Draft one as a dry run

    A dry run (the default) creates nothing in Whop and sends none of the planned requests. Where your workspace has an ad account, it reads that account from Whop and, unless the ad links to a whop.com page, asks Whop whether the pixel is on that page. It is how to try the API without spending. Needs the Drafts scope.

    POST /v1/drafts

    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://northfieldcoffee.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://northfieldcoffee.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://northfieldcoffee.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)
    Answer · 201
    JSON
    {
      "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": "Within Whop'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
    }

    POST /v1/drafts: every field

  5. Step 05: Start a live draft spending Moves money

    Moves money. Only a live draft can start; a dry run can't. Send step 04's body again with live: true and confirm_budget_usd, and start the id that answer gives. Type the daily budget back as confirm_budget_usd. Greenset refuses if the budget's worst day (210% of it) would pass your hard daily cap, if the ad balance can't cover a day, or while live writes are off. Needs the Activate scope; a key with it expires within 90 days.

    POST /v1/drafts/{id}/activate · Moves money

    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)
    Answer · 200
    JSON
    {
      "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
    }

    POST /v1/drafts/{id}/activate: every field

  6. Step 06: Stop everything

    Pauses every live campaign of this workspace's Whop ad account. Whop's Ads Terms say a paused campaign may still run for up to 24 hours after Whop receives the pause. Needs the Stop scope.

    POST /v1/stop-all

    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)
    Answer · 200
    JSON
    {
      "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": "Whop's Ads Terms say a paused campaign may still run for up to 24 hours after Whop receives the pause, and charges before the pause still apply."
    }

    POST /v1/stop-all: every field

02Keys and scopes#

  • Send the key on every call: Authorization: Bearer followed by the key. Keys start gsk_live_ and are made under Developers in Greenset; each is shown once.
  • A key acts for the one workspace it was made in, and only with the scopes it was made with. No scope implies another.
  • A key with Activate moves money, so it must expire within 90 days. Any other key expires within 365 days or never. A workspace has at most 20 active keys.
  • A key stops working when it is revoked, when it expires, and when the person who made it leaves the workspace or is switched off.
  • A key in the URL is refused (key_in_url), and so are browser cookies. There is no CORS: call the API from your server, a terminal or an editor.
  • When Greenset's Terms change, a key whose maker hasn't accepted them can still read, but every write answers 428 terms_update_required until they accept in Greenset.
ScopeNameWhat it allowsMoves money
readReadAccount, setup checks, stores, ads, drafts, campaigns and events. Changes nothing.No
storesStoresAdd a store by URL and import its catalog.No
studioStudioDraw ads from a store. Each ad uses one of the plan's images a day.No
draftsDraftsCreate drafts: a dry run by default; with live: true a draft in Whop, while live writes are on. A draft can't spend.No
activateActivateStart a draft spending. Needs the typed daily budget, and every cap and balance check still applies.Moves money
stopStopPause every live campaign (typed STOP).No
autopilotAutopilotRead guardrails and tighten them. Raising a limit needs you in the console.No
webhooksWebhooksManage webhook endpoints and read deliveries.No

03Account#

GET/v1/accountneeds Read#

Setup lamps, balance, caps and this key

The six setup checks Setup shows, with times in UTC. Also the ad balance read from Whop (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.

MCP tool get_account

GET /v1/account

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 · 200
Answer · 200
JSON
{
  "workspace": {
    "name": "Northfield Coffee"
  },
  "ad_account": {
    "id": "biz_LiveChild001",
    "name": "Northfield Coffee",
    "linked_at": "2026-10-01T09:14:02.000Z"
  },
  "setup": [
    {
      "id": "account",
      "status": "pass",
      "fact": "owner@northfieldcoffee.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": "\"Northfield Coffee\" · sacc••Qp1Z · can advertise"
    },
    {
      "id": "pixel",
      "status": "pass",
      "fact": "Whop confirmed the pixel on northfieldcoffee.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"
}
Answer fields · Account
workspaceobject · required
workspace.namestring · required
ad_accountobject, or null · required
ad_account.idstring · requiredThe Whop account (biz_…).
ad_account.namestring · required
ad_account.linked_atstring, or null · required
setuparray of object · requiredThe 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[].idstring · requiredaccount, adaccount, facebook, pixel, balance or draft.
setup[].statusstring, one of pass, next, todo, fail · required
setup[].factstring · required
balanceobject, or null · required
balance.available_usdnumber, or null · required
balance.pending_usdnumber, or null · required
balance.credits_usdnumber, or null · required
payment_failedobject, or null · required
payment_failed.sincestring · required
payment_failed.issuestring, or null · required
capsobject · required
caps.hard_daily_cap_usdnumber · required
caps.max_single_daily_usdnumber · required
caps.min_daily_budget_usdnumber · required
caps.overdeliverynumber · required
live_writesobject · required
live_writes.enabledboolean · required
live_writes.reasonstring
quotasobject · requiredToday'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.
keyobject · required
key.idstring, key_… · requiredThis key's id.
key.namestring · required
key.scopesarray of string, one of read, stores, studio, drafts, activate, stop, autopilot, webhooks · required
key.expires_atstring, or null · required
as_ofstring · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

04Stores#

GET/v1/storesneeds Read#

Connected stores

Every store connected to the workspace, with its catalog, conversions and pixel state. Credentials never appear.

MCP tool list_stores

GET /v1/stores

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 · 200
Answer · 200
JSON
{
  "data": [
    {
      "id": "conn_9Xk2Lm4Pq7Rs",
      "platform": "shopify",
      "store_url": "https://northfieldcoffee.example",
      "name": "Northfield 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
}
Answer fields · StorePage
dataarray of Store · required
data[].idstring, conn_… · requiredStore connection id.
data[].platformstring · required
data[].store_urlstring · required
data[].namestring · required
data[].statusstring · required
data[].status_detailstring, or null · required
data[].connected_atstring · required
data[].catalogobject · required
data[].conversionsobject · required
data[].pixelobject · required
has_moreboolean · required
next_cursorstring, or null · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

POST/v1/storesneeds Stores#

Add a store by URL

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 a Whop ad account that Whop suspended or restricted is refused (store_gated), as in the console; when Greenset can't confirm that with Whop, nothing is linked (upstream_error, safe to retry).

MCP tool add_store · Idempotency-Key honoured

Body

urlstring, up to 500 characters · requiredThe store's address.
limitinteger, 1 to 1000Products to import (default 250).
POST /v1/stores

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://northfieldcoffee.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://northfieldcoffee.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://northfieldcoffee.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 · 201
Answer · 201
JSON
{
  "store": {
    "id": "conn_9Xk2Lm4Pq7Rs",
    "platform": "shopify",
    "store_url": "https://northfieldcoffee.example",
    "name": "Northfield 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": []
  }
}
Answer fields · StoreAdded
storeStore · required
store.idstring, conn_… · requiredStore connection id.
store.platformstring · required
store.store_urlstring · required
store.namestring · required
store.statusstring · required
store.status_detailstring, or null · required
store.connected_atstring · required
store.catalogobject · required
store.conversionsobject · required
store.pixelobject · required
catalogobject · required
catalog.countinteger · required
catalog.totalinteger, or null · required
catalog.sourcestring · required
catalog.notesarray of string · required

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

05Studio#

POST/v1/conceptsneeds Studio#

Draw up to six ads from a store

Reads the store, cuts out its products and renders up to six ad concepts (count, default 6) in Whop'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.

MCP tool draw_concepts · Idempotency-Key honoured

Body

urlstring, up to 500 characters · required
renderbooleanDefault true.
countinteger, 1 to 6How many concepts to render (default 6); each is one image from the daily allowance.
POST /v1/concepts

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://northfieldcoffee.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://northfieldcoffee.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://northfieldcoffee.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 · 201
Answer · 201
JSON
{
  "batch_id": "bat_4e8a1c2b9d70",
  "brand": {
    "name": "Northfield Coffee",
    "platform": "shopify",
    "colors": [
      "#2B1D14",
      "#E8D9C5"
    ]
  },
  "concepts": [
    {
      "id": "cpt_1a2b3c4d5e6f",
      "batch_id": "bat_4e8a1c2b9d70",
      "rank": 1,
      "brand": "Northfield Coffee",
      "source_url": "https://northfieldcoffee.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://northfieldcoffee.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
  }
}
Answer fields · ConceptBatch
batch_idstring · requiredThis draw (bat_…).
brandobject · required
brand.namestring · required
brand.platformstring, or null · required
brand.colorsarray of string · required
conceptsarray of Concept · required
concepts[].idstring, cpt_… · requiredConcept id.
concepts[].batch_idstring · requiredThe draw it came from (bat_…).
concepts[].rankinteger · required
concepts[].brandstring, or null · required
concepts[].source_urlstring, or null · required
concepts[].hookstring, or null · required
concepts[].headlinestring, or null · required
concepts[].sublinestring, or null · required
concepts[].ctastring, or null · required
concepts[].call_to_actionstring, or null · required
concepts[].scorenumber, or null · required
concepts[].layoutstring, or null · required
concepts[].copyobject · required
concepts[].productobject, or null · required
concepts[].imagesobject · requiredThe /v1 path of each format rendered (a concept drawn without render has none).
warningsarray of string · required
imagesobject · requiredToday's image allowance (UTC day).
images.tierstring, one of free, trial, premium, agency · required
images.usedinteger · required
images.limitinteger · required
images.leftinteger · required
images.unlimitedboolean · requiredPremium and Agency: no daily count; limit is then the fair-use ceiling.

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

GET/v1/concepts/{id}needs Read#

One concept

Copy, product and image links for one concept drawn in this workspace.

MCP tool get_concept

Parameters

idstring, cpt_… (path) · requiredConcept id (cpt_ and 12 hex characters).
GET /v1/concepts/{id}

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 · 200
Answer · 200
JSON
{
  "id": "cpt_1a2b3c4d5e6f",
  "batch_id": "bat_4e8a1c2b9d70",
  "rank": 1,
  "brand": "Northfield Coffee",
  "source_url": "https://northfieldcoffee.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://northfieldcoffee.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"
  }
}
Answer fields · Concept
idstring, cpt_… · requiredConcept id.
batch_idstring · requiredThe draw it came from (bat_…).
rankinteger · required
brandstring, or null · required
source_urlstring, or null · required
hookstring, or null · required
headlinestring, or null · required
sublinestring, or null · required
ctastring, or null · required
call_to_actionstring, or null · required
scorenumber, or null · required
layoutstring, or null · required
copyobject · required
copy.headlinesarray of string · required
copy.primary_textsarray of string · required
copy.descriptionsarray of string · required
productobject, or null · required
product.titlestring, or null · required
product.pricestring, or null · required
product.urlstring, or null · required
imagesobject · requiredThe /v1 path of each format rendered (a concept drawn without render has none).
images.squarestring
images.verticalstring
images.horizontalstring

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

GET/v1/concepts/{id}/images/{format}needs Read#

A concept's rendered image

The PNG Greenset rendered for one format. These are the exact bytes a live draft uploads to Whop.

MCP tool get_concept_image

Parameters

idstring, cpt_… (path) · requiredConcept id (cpt_ and 12 hex characters).
formatstring, one of square, vertical, horizontal (path) · requiredsquare (1:1), vertical (9:16) or horizontal (1.91:1).
GET /v1/concepts/{id}/images/{format}

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']}")

Answers 200 with image/png bytes. The PNG: square 1080×1080, vertical 1080×1920, horizontal 1200×628 pixels. The samples save it to a file.

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

06Drafts#

GET/v1/draftsneeds Read#

Drafts, newest first

Dry runs and live drafts. A live draft exists in Whop as a draft campaign and cannot spend until it is activated.

MCP tool list_drafts

Parameters

limitinteger, 1 to 100 (query)Rows per page, 1–100 (default 20).
cursorstring, up to 600 characters (query)next_cursor from the previous page.
GET /v1/drafts

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 · 200
Answer · 200
JSON
{
  "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://northfieldcoffee.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
}
Answer fields · DraftPage
dataarray of Draft · required
data[].idstring, drf_… · requiredDraft id.
data[].concept_idstring, cpt_… · requiredThe concept it was drafted from.
data[].modestring, one of dry_run, live · required
data[].statusstring · required
data[].titlestring, or null · required
data[].daily_budget_usdnumber · required
data[].countriesarray of string · required
data[].destination_urlstring · required
data[].campaign_idstring, or null · required
data[].errorstring, or null · required
data[].created_atstring, or null · required
data[].updated_atstring, or null · required
data[].placementsobject · required
has_moreboolean · required
next_cursorstring, or null · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

GET/v1/drafts/{id}needs Read#

One draft, with its planned Whop requests

Includes planned_requests: every request Greenset sends (or would send) to Whop for this draft, with idempotency keys.

MCP tool get_draft

Parameters

idstring, drf_… (path) · requiredDraft id (drf_ and 12 hex characters).
GET /v1/drafts/{id}

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 · 200
Answer · 200
JSON
{
  "id": "drf_0a1b2c3d4e5f",
  "concept_id": "cpt_1a2b3c4d5e6f",
  "mode": "live",
  "status": "created",
  "title": "House blend · autumn",
  "daily_budget_usd": 25,
  "countries": [
    "US"
  ],
  "destination_url": "https://northfieldcoffee.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"
      }
    }
  ]
}
Answer fields · DraftDetail
idstring, drf_… · requiredDraft id.
concept_idstring, cpt_… · requiredThe concept it was drafted from.
modestring, one of dry_run, live · required
statusstring · required
titlestring, or null · required
daily_budget_usdnumber · required
countriesarray of string · required
destination_urlstring · required
campaign_idstring, or null · required
errorstring, or null · required
created_atstring, or null · required
updated_atstring, or null · required
placementsobject · required
placements.modestring, one of automatic, manual
placements.presetstring, one of automatic, facebook_instagram, facebook, instagram, other
placements.facebookstring or array, or null
placements.instagramstring or array, or null
placements.otherarray of object
placements.countinteger, or null
placements.ofinteger
placements.labelstring
placements.legacyboolean
planned_requestsarray of object · required
planned_requests[].stepstring · required
planned_requests[].methodstring, one of GET, POST, PUT, PATCH · required
planned_requests[].pathstring · requiredWhop API path.
planned_requests[].headersobject
planned_requests[].bodyanyThe JSON body Greenset sends.
planned_requests[].notestring

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

POST/v1/draftsneeds Drafts#

Draft an ad from a concept

Checks the budget against Greenset's minimum and your hard daily cap, cleans Whop's reserved parameters off the link, and plans the Whop requests. A dry run (the default) creates nothing in Whop and sends none of the planned requests. Where your workspace has an ad account, it reads that account from Whop and, unless the ad links to a whop.com page, asks Whop whether the pixel is on that page. live: true with confirm_budget_usd equal to daily_budget_usd creates the campaign in Whop as a draft (0 spend) when live writes are on; when they're off, you get a dry run and gated_reason says why.

MCP tool create_draft · Idempotency-Key honoured

Body

concept_idstring, cpt_… · required
destination_urlstring, up to 2000 characters · requiredWhere the ad sends people (https).
daily_budget_usdnumber, 5 to 100000 · requiredUS 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 string, 1 to 50 itemsISO 3166-1 alpha-2 (default US).
livebooleanCreate it in Whop (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 Whop: 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).
POST /v1/drafts

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://northfieldcoffee.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://northfieldcoffee.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://northfieldcoffee.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 · 201
Answer · 201
JSON
{
  "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": "Within Whop'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
}
Answer fields · DraftPlan
idstring, drf_… · requiredDraft id.
modestring, one of dry_run, live · required
gated_reasonstring, or null · required
planned_requestsarray of object · required
planned_requests[].stepstring · required
planned_requests[].methodstring, one of GET, POST, PUT, PATCH · required
planned_requests[].pathstring · requiredWhop API path.
planned_requests[].headersobject
planned_requests[].bodyanyThe JSON body Greenset sends.
planned_requests[].notestring
checksarray of object · required
checks[].idstring, one of whop_requirements, ads_agreement, payment_method, facebook_page, pixel, ad_balance, creative, policy, whop_limits · required
checks[].statusstring, one of pass, fail, unknown, review · required
checks[].detailstring · required
removed_paramsarray of object · required
removed_params[].paramstring · required
removed_params[].valuestring · required
removed_params[].reasonstring · required
resultobject, or null · requiredLive creates: the ids Whop gave back (ad_campaign_id, ad_group_id, …).
placementsobject · required
placements.modestring, one of automatic, manual
placements.presetstring, one of automatic, facebook_instagram, facebook, instagram, other
placements.facebookstring or array, or null
placements.instagramstring or array, or null
placements.otherarray of object
placements.countinteger, or null
placements.ofinteger
placements.labelstring
placements.legacyboolean
placements_checkobject, or null · requiredLive creates only: where Whop holds the ad as showing.
placements_check.okboolean · required
placements_check.in_whopobject, or null · required
placements_check.sentencestring · required

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

POST/v1/drafts/{id}/activateneeds ActivateMoves money#

Start a live draft spending

Moves money. Type the draft's daily budget back as confirm_budget_usd. Greenset then reads today's spend and every live budget from Whop, 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 Whop 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.

MCP tool activate_draft · Idempotency-Key honoured

Parameters

idstring, drf_… (path) · requiredDraft id (drf_ and 12 hex characters).

Body

confirm_budget_usdnumber, more than 0 · requiredThe 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.
POST /v1/drafts/{id}/activate · Moves money

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 · 200
Answer · 200
JSON
{
  "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
}
Answer fields · DraftActivation
activatedboolean, one of true · required
campaign_idstring · required
verdictstring · requiredWhat the guardrails said of this start: needs_approval (the typed budget was the approval) or, with Launch autonomy on Auto, allow_and_notify.
capobject, or null · requiredThe hard daily cap as activation read it.
cap.daystring · requiredYYYY-MM-DD on the ad account's clock.
cap.time_zonestring · required
cap.spent_today_usdnumber · required
cap.committed_usdnumber · required
cap.after_usdnumber · required
cap.worst_case_usdnumber · required
cap.cap_usdnumber · required
stopped_againobject, or null · requiredStop 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.pausedboolean · requiredWhop confirmed the pause.
stopped_again.unknownboolean · requiredWhop's answer to the pause was lost: it may still be spending.
stopped_again.reasonstring · requiredWhat happened, in words: tell the person this.

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

07Campaigns#

GET/v1/campaignsneeds Read#

Campaigns with Whop's numbers

Read live from Whop for the window you ask for, on your ad account's own clock, last-touch. A number Whop doesn't report stays null (never 0). ROAS counts only payments Whop processes. Every page carries as_of.

MCP tool list_campaigns

Parameters

stats_fromstring (query)YYYY-MM-DD on the account's clock (default today).
stats_tostring (query)YYYY-MM-DD (default stats_from).
limitinteger, 1 to 100 (query)Rows per page, 1–100 (default 20).
cursorstring, up to 600 characters (query)next_cursor from the previous page.
GET /v1/campaigns

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 · 200
Answer · 200
JSON
{
  "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 Whop account's reporting time zone.",
    "attribution_model": "last_touch"
  },
  "roas_label": "Whop-checkout ROAS, last-touch",
  "as_of": "2026-10-05T14:02:11.000Z"
}
Answer fields · CampaignPage
dataarray of Campaign · required
data[].idstring, or null · required
data[].titlestring, or null · required
data[].statusstring, or null · required
data[].delivery_statusstring, or null · required
data[].platformstring, or null · required
data[].objectivestring, or null · required
data[].budget_amountnumber, or null · required
data[].budget_typestring, or null · required
data[].spendnumber, or null · required
data[].spend_currencystring, or null · required
data[].impressionsnumber, or null · required
data[].clicksnumber, or null · required
data[].resultsnumber, or null · required
data[].result_eventstring, or null · required
data[].cost_per_resultnumber, or null · required
data[].purchasesnumber, or null · required
data[].purchase_valuenumber, or null · required
data[].return_on_ad_spendnumber, or null · required
data[].created_atstring, or null · required
data[].updated_atstring, or null · required
has_moreboolean · required
next_cursorstring, or null · required
windowobject · required
window.fromstring · required
window.tostring · required
window.time_zonestring · required
window.time_zone_notestring, or null · required
window.attribution_modelstring · required
roas_labelstring · required
as_ofstring · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

GET/v1/eventsneeds Read#

What Whop told Greenset, newest first

Deposits, payment failures, campaigns delivering, ads rejected, balance and pixel alerts, as recorded for this workspace.

MCP tool list_events

Parameters

limitinteger, 1 to 100 (query)Rows per page, 1–100 (default 20).
cursorstring, up to 600 characters (query)next_cursor from the previous page.
GET /v1/events

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 · 200
Answer · 200
JSON
{
  "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
}
Answer fields · EventPage
dataarray of Event · required
data[].idstring, pev_… · requiredEvent id.
data[].typestring · requiredWhop's event name as recorded (deposit.succeeded, …).
data[].resource_idstring, or null · required
data[].summarystring · required
data[].created_atstring · required
has_moreboolean · required
next_cursorstring, or null · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

08Safety#

POST/v1/stop-allneeds Stop#

Pause every live campaign

Send {"confirm": "STOP"}. Pauses every live campaign of this workspace's own Whop ad account through Whop'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 Whop. Whop's Ads Terms: a paused campaign may still run for up to 24 hours after Whop receives the pause.

MCP tool stop_all · Idempotency-Key honoured

Body

confirmstring, one of STOP · required
POST /v1/stop-all

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 · 200
Answer · 200
JSON
{
  "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": "Whop's Ads Terms say a paused campaign may still run for up to 24 hours after Whop receives the pause, and charges before the pause still apply."
}
Answer fields · StopAllResult
dry_runboolean · required
gated_reasonstring, or null · required
accountstring · requiredThe ad account, shortened.
live_foundinteger · required
paused_countinteger · required
failed_countinteger · required
resultsarray of object · required
results[].campaign_idstring · required
results[].titlestring · required
results[].statusstring · required
results[].pausedboolean · required
results[].would_pauseboolean · required
results[].whop_statusinteger, or null · required
results[].errorstring, or null · required
notestring · required

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

09Autopilot#

GET/v1/guardrailsneeds Autopilot#

Spend cap, ROAS floor, CPA target and autonomy

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.

MCP tool get_guardrails

GET /v1/guardrails

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 · 200
Answer · 200
JSON
{
  "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
}
Answer fields · Guardrails
autonomyobject · required
autonomy.readstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.pausestring, one of Notify, Suggest, Confirm, Auto · required
autonomy.budgetstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.creativestring, one of Notify, Suggest, Confirm, Auto · required
autonomy.launchstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.billingstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.destinationstring, one of Notify, Suggest, Confirm, Auto · required
hard_daily_cap_usdnumber · required
roas_floornumber · required
cpa_target_usdnumber, or null · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

PUT/v1/guardrailsneeds Autopilot#

Tighten guardrails

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.

MCP tool tighten_guardrails · Idempotency-Key honoured

Body

hard_daily_cap_usdnumber, more than 0, at most 100000US 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_floornumber, 0 to 100A multiple of ad spend, not a percent: 3 means 3×. For ad groups measured on purchases: one whose return on ad spend from Whop checkouts is under it gets no budget raise from Autopilot or a rule.
cpa_target_usdnumber, more than 0, at most 100000US 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).
PUT /v1/guardrails

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 · 200
Answer · 200
JSON
{
  "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
}
Answer fields · Guardrails
autonomyobject · required
autonomy.readstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.pausestring, one of Notify, Suggest, Confirm, Auto · required
autonomy.budgetstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.creativestring, one of Notify, Suggest, Confirm, Auto · required
autonomy.launchstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.billingstring, one of Notify, Suggest, Confirm, Auto · required
autonomy.destinationstring, one of Notify, Suggest, Confirm, Auto · required
hard_daily_cap_usdnumber · required
roas_floornumber · required
cpa_target_usdnumber, or null · required

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

10Webhooks#

GET/v1/webhooksneeds Webhooks#

Webhook endpoints

Every endpoint of the workspace. Secrets are never returned after creation.

MCP tool list_webhooks

GET /v1/webhooks

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 · 200
Answer · 200
JSON
{
  "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
}
Answer fields · WebhookEndpointPage
dataarray of WebhookEndpoint · required
data[].idstring, we_… · requiredEndpoint id.
data[].urlstring · required
data[].descriptionstring, or null · required
data[].eventsarray of string, one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low, cap.reached, autopilot.proposal, automation.fired, competitor.changed, pixel.stopped, account.restricted · required
data[].enabledboolean · required
data[].disabled_reasonstring, or null · required
data[].consecutive_failuresinteger · required
data[].disabled_atstring, or null · required
data[].created_atstring · required
data[].last_success_atstring, or null · required
data[].last_failure_atstring, or null · required
has_moreboolean · required
next_cursorstring, or null · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

POST/v1/webhooksneeds Webhooks#

Add a webhook endpoint

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.

MCP tool create_webhook · Idempotency-Key honoured

Body

urlstring, up to 500 characters · required
eventsarray of string, one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low, cap.reached, autopilot.proposal, automation.fired, competitor.changed, pixel.stopped, account.restricted, 1 to 12 items · required
descriptionstring, up to 120 characters
POST /v1/webhooks

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 · 201
Answer · 201
JSON
{
  "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)"
}
Answer fields · WebhookEndpointCreated
endpointWebhookEndpoint · required
endpoint.idstring, we_… · requiredEndpoint id.
endpoint.urlstring · required
endpoint.descriptionstring, or null · required
endpoint.eventsarray of string, one of deposit.received, campaign.submitted, campaign.live, ad.rejected, payment.failed, balance.low, cap.reached, autopilot.proposal, automation.fired, competitor.changed, pixel.stopped, account.restricted · required
endpoint.enabledboolean · required
endpoint.disabled_reasonstring, or null · required
endpoint.consecutive_failuresinteger · required
endpoint.disabled_atstring, or null · required
endpoint.created_atstring · required
endpoint.last_success_atstring, or null · required
endpoint.last_failure_atstring, or null · required
secretstring, or null · requiredThe endpoint's whsec_ signing secret, in this answer only. null in an idempotent replay.
secret_notestringOnly in an idempotent replay.

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

DELETE/v1/webhooks/{id}needs Webhooks#

Delete a webhook endpoint

Stops all deliveries to it at once. Its delivery log goes with it.

MCP tool delete_webhook · Idempotency-Key honoured

Parameters

idstring, we_… (path) · requiredEndpoint id (we_ and 16 characters).
DELETE /v1/webhooks/{id}

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 · 200
Answer · 200
JSON
{
  "deleted": true,
  "id": "we_Rk7Lq2Xm9Pz4Tn6V"
}
Answer fields · WebhookDeleted
deletedboolean, one of true · required
idstring, we_… · requiredThe endpoint deleted.

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

POST/v1/webhooks/{id}/testneeds Webhooks#

Send a test event now

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.

MCP tool test_webhook · Idempotency-Key honoured

Parameters

idstring, we_… (path) · requiredEndpoint id (we_ and 16 characters).
POST /v1/webhooks/{id}/test

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 · 200
Answer · 200
JSON
{
  "delivery_id": "dlv_Hn4Wq8Zr2Lm6Pk1S",
  "event_id": "msg_Vb5Nc9Xe3Rt7Yu2I",
  "ok": true,
  "status": 204,
  "error": null,
  "ms": 182
}
Answer fields · WebhookTestResult
delivery_idstring, dlv_… · requiredDelivery id.
event_idstring, msg_… · requiredEvent id (webhook-id).
okboolean · required
statusinteger, or null · required
errorstring, or null · required
msinteger, or null · required

Refusals

Plus the refusals every call and every write can get (401, 403, 409, 422, 428, 429, 500): Errors.

GET/v1/webhooks/{id}/deliveriesneeds Webhooks#

An endpoint's delivery log

Each delivery with its status, attempts and every attempt's HTTP status code.

MCP tool list_webhook_deliveries

Parameters

idstring, we_… (path) · requiredEndpoint id (we_ and 16 characters).
limitinteger, 1 to 100 (query)Rows per page, 1–100 (default 20).
cursorstring, up to 600 characters (query)next_cursor from the previous page.
GET /v1/webhooks/{id}/deliveries

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 · 200
Answer · 200
JSON
{
  "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
}
Answer fields · DeliveryPage
dataarray of Delivery · required
data[].idstring, dlv_… · requiredDelivery id.
data[].event_idstring, msg_… · requiredThe event's id: the webhook-id header, the same on every retry.
data[].typestring · required
data[].statusstring, one of pending, sending, succeeded, failed, cancelled · required
data[].attemptsinteger · required
data[].last_statusinteger, or null · required
data[].last_errorstring, or null · required
data[].next_attempt_atstring, or null · required
data[].created_atstring · required
data[].triesarray of object · requiredThe latest 10 attempts, newest first.
has_moreboolean · required
next_cursorstring, or null · required

Refusals

Plus the refusals every call can get (401, 403, 429, 500): Errors.

11Reference#

GET/v1/openapi.jsonpublic, no key#

This API's OpenAPI 3.1 document

Public and cacheable. The Developers screen renders its reference from this document; generate a client from it if you like.

Not an MCP tool

GET /v1/openapi.json

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 · 200
Answer · 200
JSON
{
  "openapi": "3.1.0",
  "info": {
    "title": "Greenset API",
    "version": "1.0.0"
  },
  "paths": {}
}
Answer fields · OpenApiDocument
openapistring · required
infoobject · required
pathsobject · required

12Errors#

Every refusal is {"error": {"code", "message", "request_id"}}. Branch on the code; the message is for a person. Some codes add a field at the top level: field, required_scope, quota, images, checks, cap, activated.

CodeHTTPMeaningAnswered by
unauthorized401No key, a malformed key, or a key Greenset doesn't know.Every call with a key
key_revoked401The key was revoked. Create a new one under Developers.Every call with a key
key_expired401The key passed its expiry date.Every call with a key
key_owner_inactive401The person who made the key is no longer an active member of the workspace.Every call with a key
key_in_url401A key was sent in the URL. Keys go only in the Authorization header; rotate that key.Every call with a key
forbidden_origin403Browser requests from other sites are refused. Call the API from your server.Every call with a key
insufficient_scope403The key doesn't hold the scope this route needs (required_scope).Every call with a key
loosen_requires_console403The API can tighten guardrails, never loosen them. Raise a limit in the console.PUT /v1/guardrails
invalid_request400A field is missing, has the wrong type, or is out of range (field names it).Every write (POST, PUT, DELETE); Every call with a body; Every call with query parameters
invalid_json400The body isn't valid JSON.Every call with a body
invalid_cursor400The cursor isn't one this API returned.GET /v1/drafts, GET /v1/campaigns, GET /v1/events, GET /v1/webhooks/{id}/deliveries
confirm_required400A typed confirmation is missing.POST /v1/drafts, POST /v1/drafts/{id}/activate, POST /v1/stop-all
confirm_mismatch400The typed daily budget doesn't equal the draft's daily budget.POST /v1/drafts, POST /v1/drafts/{id}/activate
destination_refused400The URL points somewhere Greenset won't fetch (private network, wrong scheme or port).POST /v1/stores, POST /v1/concepts, POST /v1/drafts
not_found404Nothing with that id in this workspace.Every call with an id in its path; POST /v1/drafts
conflict409The request can't be applied in the current state (the message says why).POST /v1/drafts, POST /v1/drafts/{id}/activate, POST /v1/stop-all, POST /v1/webhooks
no_ad_account409The workspace has no Whop ad account yet. Finish Setup.POST /v1/drafts, POST /v1/drafts/{id}/activate, POST /v1/stop-all, GET /v1/campaigns
account_not_ready409The ad account isn't ready for writes (Setup shows what's left).POST /v1/drafts, POST /v1/drafts/{id}/activate
activation_refused409A spend check refused activation (hard daily cap, ad balance, live writes, Stop everything since it began) or Whop refused the start. Nothing was started.POST /v1/drafts/{id}/activate
activation_unknown409Whop'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 Whop still shows a draft.POST /v1/drafts/{id}/activate
store_gated409Refused for one of two reasons: the store's platform can't be connected by URL, or Greenset has seen the store on a Whop ad account that Whop suspended or restricted. The message says which.POST /v1/stores
idempotency_in_progress409A request with this Idempotency-Key is still running.Every write (POST, PUT, DELETE)
idempotency_key_reused422This Idempotency-Key was used for a different request in the last 24 hours.Every write (POST, PUT, DELETE)
unprocessable422The input is well-formed but can't be used (the message says why).POST /v1/concepts, POST /v1/drafts
payload_too_large413Bodies are limited to 64 KB.Every call with a body
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.Every call with a key
quota_exceeded429Today's allowance for this kind of work is used. It resets at midnight UTC.POST /v1/stores, POST /v1/concepts
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.Every write (POST, PUT, DELETE)
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.POST /v1/stores, POST /v1/concepts
internal_error500Greenset failed. Nothing was half-done that it didn't say.Every call with a key
upstream_error502Whop or the store didn't answer as expected. Safe to retry.POST /v1/stores, POST /v1/concepts, POST /v1/drafts, POST /v1/drafts/{id}/activate, POST /v1/stop-all, GET /v1/campaigns
busy503The studio is busy rendering. Retry in a minute.POST /v1/concepts

13Limits#

Read from the code that enforces them when this page was made. Answers carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 carries Retry-After.

PlanRequests a minute, per keyAcross the workspace's keysImages a day
Free30606
Premium trial6012015
Premium120240No daily count (fair use: 300 a day)
Agency300600No daily count (fair use: 1500 a day)

Store reads: 120 a day, shared with the console (POST /v1/stores, and POST /v1/concepts with render: false). Days are UTC. GET /v1/account lists the daily allowances with what is used; store reads are scrape. Images are not in that list: POST /v1/concepts answers today's count as images.

LimitValue
Request body64 KB at most (413 payload_too_large)
MCP message72 KB at most
Active keys per workspace20
Key with Activateexpires within 90 days
Webhook endpoints per workspace10
Test events10 a minute per workspace
Idempotency-Key answers kept24 hours
Image returned inside an MCP tool result5,000,000 bytes at most; bigger images are a /v1 address to fetch

14Pages#

  • Lists answer {data, has_more, next_cursor}. Pass limit (1 to 100, default 20) and, for the next page, cursor set to the next_cursor you were given. A cursor this API didn't return is 400 invalid_cursor.
  • Newest first. When has_more is false, next_cursor is null and there is nothing more.

Paged: GET /v1/drafts, GET /v1/campaigns, GET /v1/events, GET /v1/webhooks/{id}/deliveries. One page, always has_more: false: GET /v1/stores, GET /v1/webhooks.

15Retrying safely#

  • Every write (POST, PUT, DELETE) takes an optional Idempotency-Key header: 1 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 with Idempotent-Replayed: true, instead of running twice.
  • The same key on a different request is 422 idempotency_key_reused; while the first request is still running, a retry is 409 idempotency_in_progress.
  • A 5xx answer is not stored, so a retry with the same key runs again.
  • POST /v1/webhooks answers its signing secret only the first time: a replay carries secret: null and a note.
  • Over MCP, a write tool takes the same value as its idempotency_key argument.

16Webhook events#

  • Add up to 10 endpoints per workspace: https, port 443, a public host (no IP addresses, nothing that resolves to a private network). Redirects are never followed.
  • Each delivery is a POST with webhook-id (the same on every retry, so you can drop repeats), webhook-timestamp (Unix seconds) and webhook-signature: v1, and the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your whsec_ secret after the prefix (Standard Webhooks).
  • Answer any 2xx within 10 seconds. Anything else, or no answer, is a failure.
  • The body is {type, timestamp, data}. Payloads carry ids, amounts and plain words: never a key, token, secret or email address.
  • GET /v1/events lists what Whop told Greenset under Whop's own names: a deposit Whop reports as deposit.succeeded is listed there as deposit.succeeded and sent to your endpoints as deposit.received.
  • Switching an endpoint off or on, sending a delivery again and reading every delivery are in Greenset (Developers, then Webhooks); a new secret means deleting the endpoint and adding it again.
  • 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.
Check a delivery

Node.js

import crypto from "node:crypto";

// raw: the body exactly as received (a string). headers: lower-case names.
export function verifyGreenset(raw, headers, secret) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const want = crypto.createHmac("sha256", key).update(id + "." + ts + "." + raw).digest();
  return (headers["webhook-signature"] || "").split(" ").some((part) => {
    const [v, sig] = part.split(",");
    const got = Buffer.from(sig || "", "base64");
    return v === "v1" && got.length === want.length && crypto.timingSafeEqual(got, want);
  });
}

Python

import base64, hashlib, hmac, time


def verify_greenset(raw: bytes, headers: dict, secret: str) -> bool:
    """raw: the body exactly as received (bytes). headers: lower-case names."""
    msg_id = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp", "")
    if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret[len("whsec_"):])
    signed = msg_id.encode() + b"." + ts.encode() + b"." + raw
    want = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for part in headers.get("webhook-signature", "").split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig, want):
            return True
    return False
deposit.receivedFunds reached your ad balance.deposit_id: string, or null
amount: number, or null
currency: string
campaign.submittedA draft was activated and sent to Meta for review.campaign_id: string
title: string
daily_budget_usd: number
draft_id: string, or null
campaign.liveWhop reports a campaign delivering.campaign_id: string
title: string
delivery_status: string
ad.rejectedMeta rejected an ad; Whop's first reason is included.ad_id: string, or null
title: string
reason: string
payment.failedWhop couldn't collect ad spend; delivery is paused until it's fixed.campaign_id: string, or null
reason: string
balance.lowAbout three days or less of ads left at today's budgets.balance_usd: number, or null
daily_budget_usd: number, or null
days_left: number, or null
cap.reachedToday's spend reached your hard daily cap; Greenset paused live campaigns.cap_usd: number
spent_usd: number, or null
paused_campaigns: number
day: string
autopilot.proposalAutopilot has a change waiting for your approval.proposal_id: string
summary: string
automation.firedA rule you set fired.rule_id: string
rule_name: string
action: string
entities: array of object, at most 20 items
competitor.changedA competitor you track changed an offer, a price or its ads.competitor_id: string
name: string
changes: array of object, at most 20 items
pixel.stoppedWhop stopped seeing its pixel on one of your stores.connection_id: string, or null
host: string
days_since_seen: number, or null
account.restrictedWhop reports your ad account can't launch ads.reason: string
run_ads: string, or null
webhook.testSent only by Send test event (in Greenset or POST /v1/webhooks/{id}/test).message: string
workspace: string

17MCP and AI tools#

MCP serverhttps://app.greenset.ai/mcp
  • The MCP server serves the same operations as tools over Streamable HTTP, one tool per scoped operation (20 tools), named from the operationId (getAccount is get_account). tools/list shows only the tools the key's scopes allow.
  • Protocol versions 2026-07-28 (stateless) and 2025-11-25, 2025-06-18, 2025-03-26 (start with initialize). POST one JSON-RPC message per request; GET and DELETE answer 405.
  • Authentication is the same key, as Authorization: Bearer on every request. OAuth sign-in for MCP is not offered.
  • A tool call runs the /v1 operation itself with the caller's key: the same scope check, rate limits, validation, idempotency, audit and spend checks. Failures come back as a tool result with isError and the /v1 error body.
  • A browser request from another site's Origin is refused (403 forbidden_origin). A request with no Origin header, as a terminal, an editor or a server-side client sends, is accepted.

Claude Anthropic: claude.ai and Claude Desktop, as a custom connector

Claude: Customize → Connectors → + Add → Add custom connector
Text
Name                   Greenset
Remote MCP server URL  https://app.greenset.ai/mcp
Authentication         No sign in
Request headers        Authorization: Bearer <paste your Greenset key here>

Fill the four fields as shown and put your key where the angle brackets are. Claude keeps the key, encrypted, with the connector; it isn't read from your environment. Make a key with only the scopes you want Claude to use, and an expiry. Claude calls Greenset from Anthropic's cloud, not from your computer.

Or: Team and Enterprise: Organization settings → Connectors → Add → Custom → Web
Text
Name                   Greenset
Remote MCP server URL  https://app.greenset.ai/mcp
Authentication         No sign in
Request headers        Authorization: Bearer <paste your Greenset key here>

Only an Owner adds it (on Enterprise, also someone whose role can manage Libraries). Everyone in the organization who uses it reaches Greenset with that one key: one workspace, that key's scopes. Members then connect it under Customize → Connectors.

Your keyIn Claude's connector settings, encrypted. It isn't in your environment.
CheckIn a new chat, ask Claude to use the greenset tool get_account: it answers with your setup checks. Greenset hasn't yet tried this connector with a live Claude account.

Claude Code Anthropic

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

Run it where GREENSET_KEY is set. Your shell fills the key in and Claude Code keeps it in your own user settings, for every project, never in a repository.

Or: .mcp.json at the root of a repository
JSON
{
  "mcpServers": {
    "greenset": {
      "type": "http",
      "url": "https://app.greenset.ai/mcp",
      "headers": {
        "Authorization": "Bearer ${GREENSET_KEY}"
      }
    }
  }
}

For a team: commit this file. Claude Code fills ${GREENSET_KEY} from each person's environment, so the key itself is never committed.

Your keyIn Claude Code's user settings on your computer (or each person's environment, with .mcp.json).
CheckType /mcp in Claude Code: greenset shows as connected. From the terminal: claude mcp list.
Checkedcode.claude.com/docs/en/mcp on 2026-10-06

Cursor Anysphere

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

~/.cursor/mcp.json gives every project Greenset; .cursor/mcp.json in a project gives just that one. Cursor fills ${env:GREENSET_KEY} from the environment it started in: open it from a terminal where GREENSET_KEY is set (cursor .).

Your keyOnly in your environment (GREENSET_KEY).
CheckOpen Customize in the sidebar: greenset is listed and switched on. Then ask the agent to call get_account.
Checkedcursor.com/docs/mcp on 2026-10-06
More AI tools: Codex, Warp, Windsurf, VS Code, Gemini CLI, Any other agent

Codex OpenAI: Codex CLI, the Codex IDE extension, the ChatGPT desktop app

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

Codex reads the key from GREENSET_KEY each time it connects; the key is never written to its config. The ChatGPT desktop app and the Codex IDE extension use this same setup, with any model (GPT-6 Astra too); each reads GREENSET_KEY from the environment it was opened in.

Or: ~/.codex/config.toml
TOML
[mcp_servers.greenset]
url = "https://app.greenset.ai/mcp"
bearer_token_env_var = "GREENSET_KEY"
tool_timeout_sec = 120

The same, by hand, with a tool timeout longer than Codex's default, for draw_concepts, the slowest tool. A trusted project can use .codex/config.toml instead.

Your keyOnly in your environment (GREENSET_KEY).
CheckType /mcp in Codex, or run codex mcp list.

Warp

Settings → Agents → MCP servers → + Add
JSON
{
  "greenset": {
    "url": "https://app.greenset.ai/mcp",
    "headers": {
      "Authorization": "Bearer <paste your Greenset key here>"
    }
  }
}

Paste this into the + Add box and put your key where the angle brackets are: Warp's docs don't offer environment variables in headers. Don't share this server with teammates. Warp removes env values from a shared server, not headers, so each person adds their own.

Your keyIn Warp's MCP server settings.
CheckSettings → Agents → MCP servers lists greenset. Ask Warp's agent to call the greenset tool get_account.

Windsurf Cognition: Devin Desktop, its Cascade and Devin Local agents

~/.config/devin/mcp_config.json (Windows: %APPDATA%\devin\mcp_config.json)
JSON
{
  "mcpServers": {
    "greenset": {
      "url": "https://app.greenset.ai/mcp",
      "headers": {
        "Authorization": "Bearer <paste your Greenset key here>"
      }
    }
  }
}

Open it from the Cascade panel: the … (Actions) menu at the top right, then Open MCP config file. Add greenset under mcpServers, put your key where the angle brackets are, and save. Both agents read this file: Cascade and Devin Local, the default for new tabs. Keep the key here only, never in a project's .devin folder.

Your keyIn ~/.config/devin/mcp_config.json on your computer.
CheckCascade: the MCPs section of its … (Actions) menu lists greenset. Devin Local: Open customizations, in the new-tab menu, lists it under MCP servers.

VS Code Microsoft: GitHub Copilot in agent mode

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

Save it as .vscode/mcp.json, or run MCP: Open User Configuration to have it in every workspace. VS Code asks for the key the first time and keeps it; the file never holds it.

Your keyIn VS Code's own store for input values, not in the file.
CheckRun MCP: List Servers: greenset is listed (start it if it isn't running). Then ask Copilot in agent mode to call the greenset tool get_account.

Gemini CLI Google

Terminal
Shell
gemini mcp add --scope user --transport http \
  --header "Authorization: Bearer $GREENSET_KEY" greenset https://app.greenset.ai/mcp

Run it where GREENSET_KEY is set. --scope user keeps it in ~/.gemini/settings.json, out of your project (the default is the project's .gemini/settings.json).

Your keyIn ~/.gemini/settings.json on your computer.
CheckType /mcp in Gemini CLI, or run gemini mcp list.

Any other agent

Its MCP settings
Text
URL         https://app.greenset.ai/mcp
Transport   Streamable HTTP
Header      Authorization: Bearer $GREENSET_KEY

Any client that speaks MCP over Streamable HTTP connects with these three facts.

Your keyWherever that client keeps its headers. Prefer one that reads the environment.
CheckIts tool list shows get_account, list_drafts and the rest.

18Versions and support#

  • Every route is under /v1. This is API version 1.0.0 (info.version in /v1/openapi.json).
  • No /v1 route has been removed. Routes, fields and error codes that change are listed in the changelog below; the wording of a fact or a message can change without a row.
  • The machine-readable reference is https://app.greenset.ai/v1/openapi.json (OpenAPI 3.1, public). AI agents read https://app.greenset.ai/llms.txt and https://app.greenset.ai/llms-full.txt.
  • Every answer carries an X-Request-Id header (req_ and 16 characters), and error bodies repeat it as error.request_id. Quote it when you write to hello@greenset.ai: it finds the call in your workspace's audit log.

What the API doesn't do

  • Switching a webhook endpoint off or on, sending a delivery again and rotating its secret over the API: they are in Greenset only.
  • Pages on GET /v1/stores and GET /v1/webhooks: each answers one page (a workspace has at most 10 endpoints; stores are capped at 100 per answer).
  • Test keys: there is one kind of key, gsk_live_. Use dry-run drafts to try the API without spending.
  • OAuth sign-in for MCP: connect with a key in a header.
  • Stop-all while Terms are outdated: like every write, POST /v1/stop-all answers 428 until the key's maker accepts the new Terms in Greenset.

19Changelog#

Dated when each change was made (UTC).

2026-10-28GET /v1/account no longer lists render and email in quotas, and /v1/openapi.json no longer lists them in x-greenset-limits.daily_allowances: nothing uses either allowance. Both list scrape, photos, copilot and elements.
2026-10-07This reference at /docs. Every success answer has a documented schema and an example; every answer carries X-Request-Id, and error bodies repeat it as error.request_id; every operation has curl, JavaScript and Python; a Python webhook verifier; Claude (claude.ai and Claude Desktop) setup.
2026-10-07POST /v1/drafts takes placements (where the ad shows); every draft answer carries them.
2026-10-07Request limits by plan, one budget shared by /v1 and /mcp. POST /v1/stores refuses a store past the plan's count with 402 upgrade_required.
2026-10-06The MCP server at /mcp (one tool per operation) and /llms.txt, /llms-full.txt for AI agents.
2026-10-05API 1.0.0: account, stores, concepts, drafts, activation, stop-all, campaigns, events, guardrails and webhooks; keys with eight scopes; signed outbound webhooks.