Developers

The winful API for agencies

Run winful for the brands you manage from your own software. Each brand keeps its own contract with winful. Your key reads their numbers, changes what they let you change, and sends their conversions.

What the API does

  • Reads each client's month: spend against the cap, CAC against the goal and the baseline, the drop, live variations and the machine's health.
  • Changes budget caps, the CAC goal, autonomy and the kill switch.
  • Approves or vetoes what the machine proposes, with a reason the machine reads.
  • Invites a client's customers to film, and reads the videos that come back.
  • Sets the social cadence and approves or rejects each post.
  • Sends a client's conversions, so CAC is measured on real customers.
  • Reads the ledger and invoices, where the client granted billing access.

Every client's CAC, with its baseline

GET /orgs/{org}/status returns the 90-day CAC before winful next to the new CAC, for every client you run.

A client result, measured in ILS and shown in USDCAC down 40%. Baseline $66, now $39.

Baseline CAC (90 days)$66CAC with winful (30 days)$39

Access

winful staff open your agency's account and link each client workspace you run, with the permissions that client agreed to: read, write, approve and billing_read. A workspace has one managing agency.

Keys start with wfp_live_ and are shown once. Send one as a bearer token. A key has scopes too: a call needs the permission on the client link and the same scope on the key. The admin scope manages your webhooks.

A new client? POST /orgs creates its workspace as a prospect, linked to you. Staff activate it once the client's contract is signed. Up to 5 requests can wait at a time.

Anything that lets the machine spend with less approval needs approve as well as write: autonomy made less strict, a cap raised or cleared, the kill switch turned off, a Meta connection replaced, a budget note asking for a raise. With write alone you can turn the kill switch on, lower caps and make autonomy stricter. Events carry outcomes only; vetoes and control changes go through their own endpoints.

Quickstart

  1. 01 Check the key.

    curl https://app.winful.app/api/v1/partner/me \
      -H "Authorization: Bearer $WINFUL_KEY"
  2. 02 List your clients.

    curl "https://app.winful.app/api/v1/partner/orgs?limit=50" \
      -H "Authorization: Bearer $WINFUL_KEY"
  3. 03 Read one client's numbers.

    curl https://app.winful.app/api/v1/partner/orgs/acme/status \
      -H "Authorization: Bearer $WINFUL_KEY"
  4. 04 Send a conversion. The Idempotency-Key makes a retry safe.

    curl -X POST https://app.winful.app/api/v1/partner/orgs/acme/events \
      -H "Authorization: Bearer $WINFUL_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: order-1001" \
      -d '{"v":1,"id":"order:1001","type":"order.placed","occurred_at":"2026-09-13T10:00:00Z","data":{"order_ref":"1001","customer_ref":"<sha256 of the email>","amount_minor":4900,"currency":"USD"}}'

Conventions

  • {org} is the workspace's slug or id. A workspace that is not linked to your key answers 404, exactly like one that does not exist.
  • Money is an integer in minor units next to its currency: 4120 with USD is $41.20.
  • Every status carries the baseline CAC next to the new CAC, as the app shows it to every client role. The drop, the ledger and invoices need billing_read.
  • Lists take limit (up to 200) and return next_cursor. Pass it back as cursor for the next page; null means the last page.
    curl "https://app.winful.app/api/v1/partner/orgs/acme/journal?limit=100&cursor=WyIyMDI2LTA5LTEy..." \
      -H "Authorization: Bearer $WINFUL_KEY"
    
    { "data": [ ... ], "next_cursor": null }
  • Every POST honors an Idempotency-Key header for 24 hours. The same key and body return the first response with Idempotent-Replayed: true. The same key on a different body answers 422. A replay leaves out a webhook secret or a personal invite link, and says so with secret_redacted or link_redacted.
  • Each key gets 600 requests a minute unless staff set another limit. Past it the API answers 429 with Retry-After in seconds.
  • Errors share one shape. The code is stable; the message is for people.
    { "error": { "code": "org_not_found", "message": "No client workspace with that id or slug is linked to this key." } }

Endpoints

Built from the OpenAPI spec this API serves. Paths are relative to .https://app.winful.app/api/v1/partner

Account

  • GET/meYour partner account and this keyNeeds: any key
  • GET/openapi.jsonThis specificationNeeds: no key
  • POST/mcpThe API over MCPNeeds: any key

Clients

  • GET/orgsYour clientsNeeds: read
  • POST/orgsAsk for a new client workspaceNeeds: write
  • GET/orgs/{org}/statusA client's numbersNeeds: read

Controls

  • GET/orgs/{org}/configA client's controlsNeeds: read
  • PATCH/orgs/{org}/configChange a client's controlsNeeds: write

Media

  • GET/orgs/{org}/campaignsCampaigns with metricsNeeds: read
  • GET/orgs/{org}/adsAds with metricsNeeds: read
  • GET/orgs/{org}/scoresAd scoresNeeds: read

Approvals

  • GET/orgs/{org}/approvalsOpen proposalsNeeds: read
  • POST/orgs/{org}/approvals/{id}/approveApprove a proposalNeeds: approve
  • POST/orgs/{org}/approvals/{id}/vetoVeto a proposalNeeds: approve

Videos

  • GET/orgs/{org}/videosCustomer videosNeeds: read
  • GET/orgs/{org}/video-invitesInvites to filmNeeds: read
  • POST/orgs/{org}/video-invitesInvite a customer to filmNeeds: write
  • GET/orgs/{org}/invite-settingsInvite settingsNeeds: read
  • PUT/orgs/{org}/invite-settingsChange invite settingsNeeds: write

Social

  • GET/orgs/{org}/social/settingsSocial settingsNeeds: read
  • PUT/orgs/{org}/social/settingsChange social settingsNeeds: write
  • GET/orgs/{org}/social/postsSocial postsNeeds: read
  • POST/orgs/{org}/social/posts/{id}/approveApprove a postNeeds: approve
  • POST/orgs/{org}/social/posts/{id}/rejectReject a postNeeds: approve

The machine

  • GET/orgs/{org}/journalThe journalNeeds: read
  • GET/orgs/{org}/directivesNotes to the machineNeeds: read
  • POST/orgs/{org}/directivesFile a note to the machineNeeds: write
  • POST/orgs/{org}/eventsSend a client's conversionsNeeds: write

Billing

  • GET/orgs/{org}/ledgerThe ledgerNeeds: billing_read
  • GET/orgs/{org}/invoicesInvoicesNeeds: billing_read

Integrations

  • GET/orgs/{org}/metaMeta connectionNeeds: read
  • POST/orgs/{org}/meta/connectConnect Meta with a tokenNeeds: write

Webhooks

  • GET/webhooksYour webhooksNeeds: admin
  • POST/webhooksRegister a webhookNeeds: admin
  • DELETE/webhooks/{id}Delete a webhookNeeds: admin
  • POST/webhooks/{id}/testSend a test pingNeeds: admin

Webhooks

Register an https endpoint with POST /webhooks. Every event from every client you manage arrives there, signed with the secret returned once. The client field names the workspace.

A 2xx is consumed. A 5xx, a timeout, 408 or 429 is retried with backoff, 6 attempts. Any other 4xx is not retried. Dedupe on the id. Billing events need billing_read on the link.

Events

  • action.proposed
  • action.executed
  • action.failed
  • action.vetoed
  • action.expired
  • directive.answered
  • cmo.daily_line
  • cmo.digest
  • spend.daily
  • ping
{ "v": 1, "id": "7d2f1c0e-4b7a-4c1e-9d2a-1f0e8a6b3c55", "type": "action.executed", "occurred_at": "2026-09-13T04:10:00.000Z",
  "client": "acme", "data": { "action_id": "c1a9", "kind": "budget_cut", "money_minor": 8000, "currency": "USD" } }

Verify each delivery: HMAC SHA-256 of the timestamp, a dot and the raw body, keyed with your secret. Refuse a timestamp older than 5 minutes.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as it arrived, before JSON.parse.
export function fromWinful(rawBody, headers, secret) {
  const timestamp = headers["x-winful-timestamp"];
  const signature = headers["x-winful-signature"] ?? "";
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (Number.isFinite(age) === false || age > 300) return false;
  const expected = createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Over MCP

The same key works over MCP at /api/v1/partner/mcp. list_clients returns your clients, and every other tool takes the client's slug as org. This connection never spends, changes a budget, approves or flips a kill switch.

claude mcp add --transport http winful-agency https://app.winful.app/api/v1/partner/mcp \
  --header "Authorization: Bearer wfp_live_..."