Recast Knowledge Base
Breadcrumbs

The Plans API


The Plans API lets you programmatically read the Plans you've built in the Recast app and get the budgets, configuration, and forecasted performance from each one. It mirrors what you see in the Plans tab of the app.

Plans can be created through the API in two ways: from a successful Optimization, or from scratch by sending your own budget table. You can also edit your Plan via API. Renaming and deleting a Plan or a version are UI-only.

See the Swagger documentation for full schema details.

Concepts

Term

Meaning

Plan

A saved marketing plan. This is a budget plus configuration (spikes, contextual variables, lower funnel settings) over a date range, used to forecast and optimize performance.

Plan Version

A snapshot of a Plan at a point in time. Every edit creates a new version. Only the primary version is live and refreshed weekly with new data.

Primary version

The current, editable version of a Plan. This is the version tagged with primary: true, and the one the primary_version object on the Plans Index points at. It is also the only version you can base a new version on.

Plan type

default: auto-generated by Recast, refreshed with every model update, assumes future spend follows historical patterns, covers 730 days. custom: user-built with custom spend and spike specifications. Plans you create through the API are always custom.

Status

This marks whether the current date falls within a Plans budget range. "future" Plans start in the future, "current" Plans span the current date, and "expired" Plans are entirely in the past.

Budget (summary)

The channel "legend" for a version. This shows which spend channels, non-spend channels, contextual variables, and lower funnel channels each version uses. Returned by the version show endpoint.

Budget (data table)

The full budget associated with a Plan version as a CSV. When you create a Plan or a version, this is the table you send.

Lower funnel channel caps

Shows how the spend in your lower funnel channel is planned. uncapped means it is predicted from upper funnel activity, capped means there is a maximum spend for the period, off means the channel is excluded, and manual means you provide the spend yourself in the budget table. Note that the API returns "manual" for lower funnel channels where spend is labeled as "provided" in the app.

Spikes / depvar spike groups

Promotions and holidays included in the plan, grouped by name, with the affected depvars (dependent variables) and dates.

Compatible / incompatible KPIs

Whether a given KPI can be forecast from this plan version's inputs. A KPI becomes incompatible when the plan is missing a channel, spike, or contextual variable the model needs.

Counterfactual (altcast)

A retroactive forecast of days that have already elapsed, using the plan's current model rather than whatever model existed at the time. This is different from the plan's regular forecast, which looks forward. The altcast_type field takes one of three values. It is null for the plan's regular forecast. It is "planned" when the current model is predicting the outcome of the plan version's originally specified budget. It is "actuals" when the current model is predicting the outcome of the actual spend that occurred.

Recommendations

Suggested budget reallocations to improve a forecasted outcome, available at Conservative / Moderate / Aggressive risk levels. This is available on all Forecasts associated with the Plan.

Adherence

A comparison of the spend you planned against the spend that actually happened, channel by channel. This is what the Adherence section of the Plans tab shows. Adherence is computed per Plan version and refreshed as new spend data arrives.

Report-through date

The last date covered by an Adherence run (report_through_date). Both the planned and the actual spend are reported through this date, so the two are directly comparable.

Goal

A target on a KPI inside a Plan, over a date range that sits within the Plan's period. A Plan can have multiple Goals and their date ranges may overlap. The budget and channel settings behind a Goal come from the Plan itself. This is what the Goals section of the Plans tab shows.

Start from the Index to find the Plan you care about (or its id from the Plans tab URL), then drill into its versions. Most of the time you want the primary version which is available directly in the Plans Index.

1. GET /plans                                          โ†’ find the plan, note primary_version.id
2. GET /plans/{plan_id}/versions/{version_id}           โ†’ full config for that version
3. GET /plans/{plan_id}/versions/{version_id}/budget    โ†’ the daily budget as CSV

Use the versions list when you need a history of edits to your Plan (e.g. comparing what changed between two versions of a plan).

Forecasts and Adherence require just the Plan id rather than the version id:

GET /plans/{plan_id}/forecasts?plan_version_id={version_id}     โ†’ forecasted performance
GET /plans/{plan_id}/adherence?plan_version_id={version_id}    โ†’ planned vs. actual spend

Find your Goals using the Goals Index endpoint then use the forecasts endpoint to get the Goal probablity

GET /plans/{plan_id}/goals                      โ†’ the Plan's Goals, each with its latest forecast_id
GET /plans/{plan_id}/forecasts/{forecast_id}    โ†’ pacing and success probability for that Goal

To build a Plan from scratch, start from what your models already know, so that the channel names, contextual variables, and spikes you send are ones the models recognise:

1. GET /kpis                            โ†’ the depvar slugs behind your KPIs
2. GET /deployments?active=true         โ†’ keep the deployments whose dashboard_slug is one of those slugs
3. GET /deployments/{deployment_id}     โ†’ the channel names, contextual variables, and spikes you can use
4. POST /plans                          โ†’ label, start_date, end_date and a budget table

To change a Plan, send your change as a new version. The budget is applied as a patch โ€” you only send the cells you want to change, and everything you leave out is inherited from the version you based the edit on:

1. GET /plans                                          โ†’ note primary_version.id
2. POST /plans/{plan_id}/versions                      โ†’ { base_version_id, form } carrying only the changes

Fetch the current budget first (GET /plans/{plan_id}/versions/{version_id}/budget) when you need to see what you're changing, or when your edit is relative to the current numbers โ€” for example scaling a channel by 10%.

If you would rather not build a budget table by hand, creating a Plan from a successful Optimization gives you one derived from the Optimizer's recommended allocation, which you can then edit as a new version.

To set a target on a Plan, create a Goal against one of the KPIs the Plan can forecast, then read the forecast it produces:

1. GET /plans/{plan_id}/versions/{version_id}   โ†’ compatible_kpis, and the Plan's date range
2. POST /plans/{plan_id}/goals                  โ†’ kpi_id, name, start_date, end_date, goal_value
3. GET /plans/{plan_id}/goals                   โ†’ poll until processing_status is success, note forecast_id
4. GET /plans/{plan_id}/forecasts/{forecast_id} โ†’ pacing and success probability

The Endpoints

1. Index GET /v1/clients/{client_slug}/plans

Returns every Plan visible in the UI with summary information about the primary version of each Plan.

Query parameters (all optional):

Param

Type

Description

page

integer

Default 1

per_page

integer

Default 25, max 100

plan_type

string

Filter to default or custom

label

string

Partial, case-insensitive match on the plan's label

status

string

Filter to one or more of expired, current, future (comma-separated for multiple, e.g. status=current,expired)

kpi_id

string (UUID)

Filter to plans whose primary version can forecast this KPI

created_by

string

Filter to plans created by a specific user (email), or Recast for system-generated plans

Response

JSON
{
  "data": [
    {
      "id": "8d777078-044e-4e3d-8d95-8dc14daf8d93",
      "label": "Q3 Growth Plan",
      "plan_type": "custom",
      "status": "current",
      "start_date": "2026-07-01",
      "end_date": "2026-09-30",
      "created_by": "user@example.com",
      "primary_version": {
        "id": "4b026308-cce1-42f0-a1ac-2b6b902766ec",
        "label": "Plan Version 14 2026-06-01 15:30:05",
        "plan_version_id": "v3",
        "primary": true,
        "total_spend": 1250000.0,
        "created_by": "user@example.com"
      },
      "created_at": "2026-05-01T12:00:00Z",
      "updated_at": "2026-06-10T09:30:00Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 25, "total_pages": 1, "total_count": 3 }
}

2. Version list GET /v1/clients/{client_slug}/plans/{plan_id}/versions

This endpoint returns all versions of one Plan, ordered by creation date descending.

Response

JSON
{
  "data": [
    {
      "id": "4b026308-cce1-42f0-a1ac-2b6b902766ec",
      "plan_version_id": "v3",
      "label": "Plan Version 14 2026-06-01 15:30:05",
      "plan_id": "8d777078-044e-4e3d-8d95-8dc14daf8d93",
      "primary": true,
      "total_spend": 1250000.0,
      "created_by": "user@example.com",
      "created_at": "2026-05-01T12:00:00Z",
      "updated_at": "2026-06-10T09:30:00Z"
    }
  ]
}

Exactly one version per plan has primary: true. Because the list is ordered by creation date descending, the most recently created version is both the first entry and the primary one.

3. Version show GET /v1/clients/{client_slug}/plans/{plan_id}/versions/{id}

This endpoint returns the full Plan summary for one version of a plan. You will need to provide client_slug, plan_id, and the version id to specify the version you want to return.

Response

JSON
{
  "id": "4b026308-cce1-42f0-a1ac-2b6b902766ec",
  "plan_version_id": "v3",
  "label": "Plan Version 14 2026-06-01 15:30:05",
  "plan_id": "8d777078-044e-4e3d-8d95-8dc14daf8d93",
  "primary": true,
  "status": "current",
  "start_date": "2026-07-01",
  "end_date": "2026-09-30",
  "created_by": "user@example.com",
  "total_spend": 1250000.0,
  "spike_type": "custom",
  "budget_summary": {
    "spend_channels": ["meta", "google", "tiktok"],
    "non_spend_channels": ["organic"],
    "contextual_variables": ["holiday", "promo"],
    "lower_funnel_channels": ["branded_search"]
  },
  "lower_funnel_channel_caps": [
    { "channel_name": "branded_search", "option": "capped", "cap": 50000.0 }
  ],
  "depvar_spike_groups": [
    {
      "spike_name": "Black Friday",
      "depvars": [
        { "depvar_slug": "total_sales", "dates": ["2026-11-27"] }
      ]
    }
  ],
  "compatible_kpis": [
    { "id": "d66d6adb-1d17-40cc-9bc7-f4bc6c78ec2e", "slug": "walmart_revenue", "label": "Walmart Revenue" }
  ],
  "incompatible_kpis": [],
  "created_at": "2026-05-01T12:00:00Z",
  "updated_at": "2026-06-10T09:30:00Z"
}

Note: budget_summary here shows only the channels that are included in the plan not the daily spend. Use the budget endpoint below to get the complete budget for a Plan version.

4. Version budget GET /v1/clients/{client_slug}/plans/{plan_id}/versions/{id}/budget

Accepts three optional parameters: start_date, end_date, granularity.

If none are provided, returns the full daily budget data table for a version as a CSV.

granularity can be one of "total", "monthly", "weekly", or "daily"; the CSV will be summarized to that level of granularity.

start_date and end_date can be used to trim the range of dates sent in the response.

The first column is date for daily granularity. For other granularities, the first columns are start_date and end_date. The remaining columns are the channels listed in the version's budget_summary (spend_channels + non_spend_channels + contextual_variables + lower_funnel_channels).

date,meta,google,tiktok,holiday,promo
2026-07-01,1000.0,2000.0,500.0,0,1
2026-07-02,1100.0,2100.0,550.0,0,1

The daily CSV is also the table format the create and version endpoints accept. On create you send the whole table; on a new version you can send just the cells you want to change.

5. Forecasts & Counterfactuals GET /v1/clients/{client_slug}/plans/{plan_id}/forecasts

Plan-driven forecasts are surfaced as a nested resource under the plan, not under an individual version. There is no version-scoped forecasts path. To narrow to one version, pass plan_version_id as a query parameter on this endpoint. Each entry is a normal Forecast result (see the Forecaster API), tagged with the plan version it belongs to.

  • GET /plans/{plan_id}/forecasts shows forecasts across every version of the plan. Pass planned or actuals into altcast_types to get the counterfactual forecasts. Pass goal_id to narrow to the forecasts behind one Goal.

  • GET /plans/{plan_id}/forecasts/{forecast_id} shows a single forecast from a particular Plan version. When the forecast belongs to a Goal, the response also includes goal_id and a goal_highlights block โ€” see Goal forecasts at the end of this section.

  • GET /plans/{plan_id}/forecasts/{forecast_id}/downloads/{key}: downloads a CSV for one of the forecast's results; for downloads with a date column (often called id), start_date and end_date can be used to set the range of dates the API will return (if not provided, all dates will be returned).

Here is an example index response.

JSON
{
  "data": [
    {
      "id": 321,
      "name": "Holiday spend forecast",
      "status": "success",
      "altcast_type": null,
      "plan_version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "plan_version_number": "v3",
      "kpi_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "kpi_label": "Walmart Revenue",
      "created_at": "2026-01-15T09:24:00Z",
      "updated_at": "2026-01-15T09:31:00Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 25, "total_pages": 4, "total_count": 87 }
}

Here is an example show response.

JSON
{
  "id": 321,
  "name": "Holiday spend forecast",
  "status": "success",
  "altcast_type": null,
  "start_date": "2026-07-05",
  "end_date": "2026-07-31",
  "plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "plan_label": "Q3 Growth Plan",
  "plan_version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "plan_version_number": "v3",
  "kpi_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "kpi_label": "Walmart Revenue",
  "form": {
    "budget": [
      ["date", "facebook", "google"],
      ["2026-12-01", "10000", "15000"],
      ["2026-12-02", "10000", "15000"]
    ],
    "custom_model_type": "roi",
    "start_date": "2026-07-21",
    "end_date": "2026-07-21",
    "lower_funnel_caps": {},
    "run_recommendations": false,
    "depvar_configurations": [
      { "name": "US Brand", "deployment_id": 456, "multiplier": 1, "spikes": [] }
    ]
  },
  "results": [
    {
      "depvars": [ { "name": "US Brand", "deployment_id": 456, "weight": 1 } ],
      "expected_outcome": 1280000,
      "total_forecasted_spend": 609524,
      "expected_blended_roi": 2.1,
      "expected_observed_paid_roi": 2.0,
      "expected_roi": 1.9,
      "expected_outcome_quantiles": { "median": 1280000.0, "p25": 1265000.0, "p75": 1295000.0 },
      "expected_blended_roi_quantiles": { "median": 2.1, "p25": 2.05, "p75": 2.15 },
      "expected_observed_paid_roi_quantiles": { "median": 2.0, "p25": 1.9, "p75": 2.1 },
      "expected_roi_quantiles": { "median": 1.9, "p25": 1.8, "p75": 2.0 },
      "downloads": [ { "description": "Budget", "key": "budget" } ]
    }
  ],
  "created_at": "2026-01-15T09:24:00Z",
  "updated_at": "2026-01-15T09:31:00Z"
}

Counterfactuals (altcast_type=actuals or planned): the API returns the underlying forecasts, not the full comparison view shown in the UI's Counterfactual section. It is null for the plan's regular forecast. It is "planned" for a forecast of the plan version's originally specified budget. It is "actuals" for a forecast of the actual budget spent so far.

Both counterfactual entries are ordinary Forecast results with the same shape as any other forecast.

What start_date/end_date mean:

  • Regular forecast (altcast_type: null): start_date/end_date describe only the period that is actually being forecasted going forward. The daily output can extend earlier than start_date, into dates that have already elapsed โ€” those earlier dates are the actual historical outcome, not a forecast.

  • Counterfactual of the actual budget (altcast_type: "actuals"): a fully retroactive forecast. start_date/end_date cover exactly the dates where a complete actual budget is available across every channel included in the forecast, and the entire daily output over that range is counterfactual.

  • Counterfactual of the planned budget (altcast_type: "planned"): start_date/end_date span the retroactive forecast period. Dates after end_date are not in-sample and are a regular forecast of the planned budget. Aggregate metrics like expected_outcome are computed over the entire range of the plan, not just start_dateโ€“end_date.

Note: the forward-looking portion of the planned counterfactual does not match what you get by calling the plan's regular forecast for the same dates, because it takes into account the retroactive forecast for the in-sample time period.

Goal forecasts

When a forecast is associated with a Goal, the show response carries two extra fields on top of the payload above: goal_id, and a goal_highlights block with the pacing and probability values you see along with a Goal in the app.

JSON
{
  "goal_id": 42,
  "goal_highlights": {
    "projected": 8000000.0,
    "pacing": 1500000.0,
    "success_probability": 0.72,
    "details": {
      "spend":       { "so_far": 400000.0,  "forecasted": 500000.0,  "projected": 900000.0 },
      "kpi":         { "so_far": 3200000.0, "forecasted": 4800000.0, "projected": 8000000.0 },
      "blended_roi": { "so_far": 8.0,       "forecasted": 9.6,       "projected": 8.89 }
    }
  }
}

How to read it:

  • projected is where the KPI is expected to land over the Goal's window. It is the same number as details.kpi.projected.

  • pacing is the distance from the target: projected minus the Goal's goal_value. A positive number means the Goal is projected to be beaten, a negative number means it is projected to fall short.

  • success_probability is the share of simulations that reach the target, between 0 and 1.

  • details breaks spend, KPI, and blended ROI into what has already happened (so_far), what is still forecast to happen (forecasted), and the two combined (projected). For spend and KPI, so_far + forecasted = projected.

  • A figure in details.blended_roi is null where the matching spend is 0, because ROI is undefined with no spend. This is expected for a Goal that has not started in data terms yet, rather than a ROI of zero.

Because so_far is measured against the data the model has, a Goal whose window is still beyond the model's data end reports so_far: 0 throughout, and everything in projected is forecast rather than realized.

A Goal keeps its forecast history: every time the Plan or the model changes, or the Goal's target is edited, the Goal gets a new forecast and the earlier ones are kept, so you can see how the outlook shifted over time. Each forecast's goal_highlights were computed against the target that was in place when it ran, so if a Goal's target has been edited, older forecasts report pacing against the earlier number. The Goal's forecast_id always points at the latest forecast, so use that for the current picture and the goal_id filter to go through the history.

6. Adherence: GET /v1/clients/{client_slug}/plans/{plan_id}/adherence

Adherence compares the spend you planned against the spend that actually happened, channel by channel. It is the API equivalent of the Adherence section of the Plans tab. Use it to check whether a Plan is being followed, to find the channels that have drifted, and to explain why a forecast and an actual outcome disagree.

Adherence is computed per Plan version. A new adherence report is created every time new spend data arrives, so a Plan accumulates reports over time. The index returns all of them, across every version of the Plan. You can filter with plan_version_id and take the most recent report when you want the current picture.

Adherence currently covers spend channels only. Non-spend channels are not included.

If there is no actual spend data available yet for a Plan's period, no adherence reports exist for it and the index returns an empty list. Planned and actual spend are both reported through report_through_date.

Adherence endpoints:

  • GET /plans/{plan_id}/adherence: adherence reports across every version of the plan, paginated. plan_version_id is an optional query parameter to filter to one version.

  • GET /plans/{plan_id}/adherence/{id}: highlights and available downloads for a single adherence report, where id is that report's id.

  • GET /plans/{plan_id}/adherence/{id}/downloads/{key}: downloads one of the report's CSVs.

Here is an example show response.

JSON
{
  "id": 4211,
  "plan_id": "8d777078-044e-4e3d-8d95-8dc14daf8d93",
  "plan_version_id": "4b026308-cce1-42f0-a1ac-2b6b902766ec",
  "plan_version_number": "v3",
  "status": "success",
  "report_through_date": "2026-06-15",
  "highlights": {
    "total": {
      "planned_spend": 1250000,
      "actual_spend": 1100000
    },
    "spend_channels": {
      "upper_funnel": [
        {
          "channel_name": "meta",
          "planned_spend": 500000,
          "actual_spend": 450000
        }
      ],
      "lower_funnel": [
        {
          "channel_name": "branded_search",
          "lf_option": "capped",
          "planned_spend": null,
          "actual_spend": 48000
        }
      ]
    }
  },
  "downloads": [
    { "description": "All channels adherence", "key": "all-channels-adherence" },
    { "description": "Meta adherence", "key": "meta-adherence" }
  ],
  "created_at": "2026-05-01T12:00:00Z",
  "updated_at": "2026-06-10T09:30:00Z"
}

A lower funnel channel shows planned spend only when that spend was provided in the Plan (lf_option: "manual"). When the option is capped or uncapped the Plan doesn't specify a spend figure, so planned_spend is null and only the actual is reported.

7. Create Plan POST /v1/clients/{client_slug}/plans

There is one create endpoint, and the body you send decides which of two paths you get:

  • From an Optimization โ€” send optimization_id and label. Recast derives the budget, dates, spikes, and lower funnel settings from the Optimization's results.

  • From scratch โ€” send label, start_date, end_date and a budget table, plus the optional spike and lower funnel settings. You author every input yourself, the same way you would in the UI.

The two are mutually exclusive, and optimization_id takes precedence. If it is present the Optimization path is taken and the rest of the form is ignored, so a stray optimization_id left in a payload will discard the budget you sent โ€” or fail validation against a field you did not think you were using.

Both paths return 201 with the new Plan's id and nothing else:

JSON
{ "id": "8d777078-044e-4e3d-8d95-8dc14daf8d93" }

Read the Plan back the way you would read any Plan: the Index (or the "List Plan Versions" endpoint) gives you the primary version's id, and "Show Plan Version" gives you the full configuration that was stored. A newly created Plan has plan_type: "custom" and exactly one version, which is its primary version.

Creating a Plan is synchronous โ€” there is no processing state to poll โ€” but it is not instantaneous, and how long it takes varies with the date range, the number of channels, and the volume of data behind the models. Leave your HTTP client on a generous timeout rather than a couple of seconds.

There is no DELETE for Plans or Plan versions, so a Plan you create through the API stays on the client until it is removed in the UI. Label test Plans clearly.

7a. From an Optimization

Once you have a successful Optimization, you can save it as a Plan using the Create Plan endpoint. This allows you to see this shifted impact of your Optimized budget over the period of your spend. It also allows you to track progress and changes against your expected outcome during the period.

To create a Plan, simply provide an Optimization ID in the JSON body of your request. This will return a Plan ID. You can then use the Plan ID in the "List Plan Versions" endpoint to retrieve the Primary Version ID and use the "Show Plan Version" Endpoint to view a summary of the Plan configuration.

JSON
{
  "form": {
    "optimization_id": 1003941377,
    "label": "Q3 Growth Plan"
  }
}

label is the only value you author on this path. The dates, budget, spikes, and lower funnel caps all come from the Optimization, and cannot be overridden here โ€” if you want to change any of them, create the Plan and then edit it as a new version (section 8).

The source Optimization has to have finished successfully. One that is still processing, errored, or was cancelled has no results to derive a budget from and is rejected with 422.

Spikes are carried over from the Optimization, but only the spike dates that fall inside the Plan's own derived date range. A spike entirely outside that window is dropped, which is expected scoping rather than a missing setting.

7b. From scratch

Send the Plan's window and a budget table, and Recast builds the Plan from those inputs directly.

JSON
{
  "form": {
    "label": "Q3 Growth Plan",
    "start_date": "2026-07-01",
    "end_date": "2026-07-03",
    "budget": [
      ["date", "meta", "google", "holiday"],
      ["2026-07-01", "1000", "2000", "0"],
      ["2026-07-02", "1000", "2000", "0"],
      ["2026-07-03", "1500", "2000", "1"]
    ],
    "spike_type": "custom",
    "depvar_spike_groups": [
      {
        "spike_name": "Summer Sale",
        "depvars": [
          { "depvar_slug": "total_sales", "dates": ["2026-07-03"] }
        ]
      }
    ],
    "lower_funnel_channel_caps": [
      { "channel_name": "branded_search", "option": "capped", "cap": 50000 }
    ]
  }
}

The form fields

Field

Required

Description

label

yes

The Plan's name. Must be non-empty and must not duplicate the label of an existing Plan on the client.

start_date

yes

YYYY-MM-DD. The first day of the Plan.

end_date

yes

YYYY-MM-DD. Must be strictly after start_date, so the shortest possible Plan is two days.

budget

yes

The daily budget table (see below).

lower_funnel_channel_caps

no

How each lower funnel channel's spend is planned.

spike_type

no

model (default) uses the spikes the models already know. custom lets you specify your own with depvar_spike_groups.

depvar_spike_groups

no

Your own promotions and holidays. Only accepted alongside spike_type: "custom".

Read the created version back to confirm your settings landed as you expected.

8. Create Plan version POST /v1/clients/{client_slug}/plans/{plan_id}/versions

Editing a Plan means adding a version to it. This endpoint copies the Plan's primary version, applies the changes you send, and makes the result the new primary version. The version you edited stays in the history, unchanged โ€” earlier versions of a Plan can never be modified.

JSON
{
  "base_version_id": "4b026308-cce1-42f0-a1ac-2b6b902766ec",
  "form": {
    "budget": [
      ["date", "meta", "google"],
      ["2026-07-01", "1100", "2000"],
      ["2026-07-02", "1100", "2000"],
      ["2026-07-03", "1600", "2000"]
    ]
  }
}

base_version_id is required to make sure edits made by two users at the same time are not overwritten. This must be the Plan's current primary version.

What is editable

Field

Notes

budget

A patch: only the cells you send are changed. Dates must fall inside the base version's range

lower_funnel_channel_caps

Merged per channel โ€” see below

spike_type

model or custom

depvar_spike_groups

Only alongside spike_type: "custom"

label, start_date, end_date and optimization_id are not editable and are rejected with 422. The new version keeps the base version's date range, and version labels are generated for you. Renaming and deleting a version are UI-only.

Anything you leave out of the form keeps the base version's value, so a form carrying only lower_funnel_channel_caps is a perfectly good request and leaves the budget untouched.

So "raise Meta by 10%" is a small payload: read the current Meta column, scale it, and send a table with just date and meta.

JSON
{
  "base_version_id": "4b026308-cce1-42f0-a1ac-2b6b902766ec",
  "form": {
    "budget": [
      ["date", "meta"],
      ["2026-07-02", "1650"]
    ]
  }
}

That request changes Meta's spend on 2 July and nothing else.

lower_funnel_channel_caps merges the same way: channels you list are updated, channels you leave out keep the base version's setting, and the array never shrinks.

spike_type and depvar_spike_groups are the exception โ€” they are replaced rather than merged. Switching spike_type back to model stops the custom spike groups being applied, and the version reports no depvar_spike_groups.

After a successful call

The response is the new version's id and nothing else:

JSON
{ "id": "9c1e5f7a-2b3d-4e6f-8a90-1c2d3e4f5a6b" }

The new version is now primary: true

9. Read Goals GET /v1/clients/{client_slug}/plans/{plan_id}/goals

A Goal is a target on a KPI inside a Plan, over a date range that sits within the Plan's period. This endpoint lists all the Goals associated with a Plan and the latest forecast for each. A Plan can have several Goals and their date ranges may overlap.

Query parameters (all optional):

Param

Type

Description

page

integer

Default 1

per_page

integer

Default 25, max 100

kpi_id

string (UUID)

Filter to Goals on one KPI

status

string

Filter to one or more of expired, current, future (comma-separated for multiple, e.g. status=current,future)

Response

JSON
{
  "data": [
    {
      "id": 42,
      "name": "Q3 Revenue Goal",
      "goal_value": 6500000.0,
      "status": "current",
      "processing_status": "success",
      "start_date": "2026-07-01",
      "end_date": "2026-09-30",
      "kpi_id": "d66d6adb-1d17-40cc-9bc7-f4bc6c78ec2e",
      "kpi_label": "Walmart Revenue",
      "forecast_id": 321,
      "created_at": "2026-05-01T12:00:00Z",
      "updated_at": "2026-06-10T09:30:00Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 25, "total_pages": 1, "total_count": 3 }
}

goal_value is the KPI target. processing_status tells you whether the Goal's forecast has finished computing. forecast_id is the Goal's latest forecast.

There is no Goal show endpoint. Pacing, success probability, and the spend / KPI / ROI breakdown all come from the Goal's forecast which can be found using the Forecast endpoints. The Forecast Index endpoint lists all the forecasts associated with a Goal and the Forecast Show endpoint can be used to get the details of each Goal Forecast.

GET /plans/{plan_id}/goals                      โ†’ find the goal, note its forecast_id
GET /plans/{plan_id}/forecasts/{forecast_id}    โ†’ goal_id and goal_highlights
GET /plans/{plan_id}/forecasts?goal_id={goal_id} โ†’ every forecast for that goal, to see how it changed

10a. Create a Goal POST /v1/clients/{client_slug}/plans/{plan_id}/goals

Goals can be created, edited, and deleted through the API. A Goal is attached to the Plan rather than to a particular version, so it survives new Plan versions.

POST   /plans/{plan_id}/goals             โ†’ create a Goal, returns its id
PATCH  /plans/{plan_id}/goals/{goal_id}   โ†’ change a Goal's name, dates, or target
DELETE /plans/{plan_id}/goals/{goal_id}   โ†’ delete a Goal and its forecasts

goal_id is the integer id from the Goals Index.

Send the KPI you are targeting, a name, the window, and the target value.

JSON
{
  "form": {
    "kpi_id": "868d6c9f-a7b7-4050-9e99-c8e54965c718",
    "name": "Q3 Revenue Goal",
    "start_date": "2026-07-01",
    "end_date": "2026-09-30",
    "goal_value": 1500000
  }
}

The form fields

Field

Required

Description

kpi_id

yes

The KPI the Goal targets. Must be one of the compatible_kpis on the Plan's primary version.

name

yes

The Goal's identifying name.

start_date

yes

YYYY-MM-DD. Must fall on or after the Plan's start date.

end_date

yes

YYYY-MM-DD. Must fall on or before the Plan's end date, and inside the KPI's forecast horizon.

goal_value

yes

The target value for the KPI over the Goal's window. A whole number of at least 1.

Read the Goal back with the Goals Index in section 9.

10b. Edit a Goal PATCH /v1/clients/{client_slug}/plans/{plan_id}/goals/{goal_id}

Send only the fields you want to change. Anything you leave out keeps its current value.

JSON
{
  "form": {
    "goal_value": 1800000
  }
}

What is editable

Field

Notes

name

Editable. Cannot be cleared.

start_date

Editable, within the Plan's period.

end_date

Editable, within the Plan's period. Cannot be moved before the KPI's model date.

goal_value

Editable. The same whole-number rule as create applies.

kpi_id cannot be changed once the Goal exists. To point a Goal at a different KPI, delete it and create a new one.

10c. Delete a Goal DELETE /v1/clients/{client_slug}/plans/{plan_id}/goals/{goal_id}

Send no body. Returns 204 with an empty body when the Goal has been deleted, and 404 when the Goal does not exist.

Deleting a Goal also deletes every forecast produced for it, which removes the record of how the Goal's outlook changed over time. This cannot be undone.

Skill file (Beta)

We have a skill file that can help AI coding assistants get up to speed on the Plans API. It is in beta and is best used for writing boilerplate code. Please review any generated code before running it. You can access the skill file by downloading it here. This Skill is available if you install the Claude plugin or can be downloaded directly from Github.

Code Examples

R

R
# Recast Plans API: R Usage Example
#
# Workflow: List plans โ†’ find primary version โ†’ fetch version detail โ†’ download budget CSV
#
# Prerequisites:
#   install.packages(c("httr2", "jsonlite"))
#   Add to .Renviron: RECAST_PAT=gr_your_token_here
#     Generate your token: log into the Recast app โ†’ click your email (top right) โ†’ Generate API token
#     Store it in ~/.Renviron as: RECAST_PAT=gr_your_token_here
#     Never hard-code or share this token.

library(httr2)
library(jsonlite)

# โ”€โ”€ Edit these โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
CLIENT_SLUG <- "myslug"                    # Your client slug (visible in the app URL)
PLAN_LABEL  <- "Q3 Growth Plan"            # Set to NULL to just use the first plan returned

# โ”€โ”€ Rarely needs changing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
BASE_URL <- "https://api.getrecast.com"
PAT      <- Sys.getenv("RECAST_PAT")          # Set in ~/.Renviron, never hard-coded

# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
parse <- \(resp) resp |> resp_body_string() |> fromJSON(simplifyVector = FALSE)

check <- function(resp, expected = 200) {
  if (resp_status(resp) != expected) {
    body <- tryCatch(resp_body_string(resp), error = \(e) "(no body)")
    stop(sprintf("HTTP %d: %s", resp_status(resp), body))
  }
  resp
}

api_request <- function(...) {
  request(BASE_URL) |>
    req_url_path_append("v1", "clients", CLIENT_SLUG, ...) |>
    req_auth_bearer_token(PAT) |>
    req_headers(Accept = "application/json") |>
    req_error(is_error = \(resp) FALSE)
}

# โ”€โ”€ Step 1: Find the plan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
plans <- api_request("plans") |>
  req_url_query(per_page = 100) |>
  req_perform() |> check() |> parse() |>
  (\(r) r$data)()

plan <- if (!is.null(PLAN_LABEL)) {
  Filter(function(p) p$label == PLAN_LABEL, plans)[[1]]
} else {
  plans[[1]]
}
cat(sprintf("Using plan: %s (%s)\n", plan$label, plan$id))

version_id <- plan$primary_version$id  # every plan has a primary version

# โ”€โ”€ Step 2: Fetch the version detail โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
version <- api_request("plans", plan$id, "versions", version_id) |>
  req_perform() |> check() |> parse()
cat(sprintf("Version %s: total_spend=%s, channels=%s\n",
            version$version_number, version$total_spend,
            paste(version$budget$spend_channels, collapse = ", ")))

# โ”€โ”€ Step 3: Download the daily budget CSV โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
csv_resp <- api_request("plans", plan$id, "versions", version_id, "budget") |>
  req_headers(Accept = "text/csv") |>
  req_perform() |> check()

df <- read.csv(text = resp_body_string(csv_resp), check.names = FALSE)
write.csv(df, file = "plan_budget.csv", row.names = FALSE)
cat(sprintf("Saved plan_budget.csv (%d rows)\n", nrow(df)))

# โ”€โ”€ Step 4 (optional): Raise Meta's spend by 10% as a new version โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# The budget is applied as a patch, so send only the column you are changing โ€”
# every other channel, date and contextual variable is inherited unchanged.
budget_table <- c(
  list(list("date", "meta")),
  lapply(seq_len(nrow(df)), function(i) list(as.character(df$date[i]),
                                             as.character(df$meta[i] * 1.10)))
)

new_version <- api_request("plans", plan$id, "versions") |>
  req_headers(`Content-Type` = "application/json") |>
  req_body_json(list(
    base_version_id = version_id,   # must be the CURRENT primary version, or you get a 409
    form = list(budget = budget_table)
  ), auto_unbox = TRUE) |>
  req_perform() |> check(expected = 201) |> parse()

cat(sprintf("Created version %s, now the primary version\n", new_version$id))

Python

Python
# Recast Plans API: Python Usage Example
#
# Workflow: List plans โ†’ find primary version โ†’ fetch version detail โ†’ download budget CSV
#
# Prerequisites:
#   pip install requests pandas
#   Generate your token: log into the Recast app โ†’ click your email (top right) โ†’ Generate API token
#   Set as an environment variable: export RECAST_PAT=gr_your_token_here
#   Never hard-code or share this token.

import os
import io
import requests
import pandas as pd

# โ”€โ”€ Edit these โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
CLIENT_SLUG = "myslug"                     # Your client slug (visible in the app URL)
PLAN_LABEL  = "Q3 Growth Plan"             # Set to None to just use the first plan returned

# โ”€โ”€ Rarely needs changing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
BASE_URL = "https://api.getrecast.com"
PAT      = os.environ["RECAST_PAT"]           # Set via: export RECAST_PAT=gr_your_token_here

BASE_PATH = f"{BASE_URL}/v1/clients/{CLIENT_SLUG}"
HEADERS   = {"Authorization": f"Bearer {PAT}", "Accept": "application/json"}

def check(resp, expected=200):
    if resp.status_code != expected:
        raise Exception(f"HTTP {resp.status_code}: {resp.text or '(no body)'}")
    return resp

# โ”€โ”€ Step 1: Find the plan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
plans = check(requests.get(f"{BASE_PATH}/plans", headers=HEADERS, params={"per_page": 100})).json()["data"]
plan = next((p for p in plans if p["label"] == PLAN_LABEL), plans[0]) if PLAN_LABEL else plans[0]
print(f"Using plan: {plan['label']} ({plan['id']})")

version_id = plan["primary_version"]["id"]  # every plan has a primary version

# โ”€โ”€ Step 2: Fetch the version detail โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
version = check(requests.get(f"{BASE_PATH}/plans/{plan['id']}/versions/{version_id}", headers=HEADERS)).json()
print(f"Version {version['version_number']}: total_spend={version['total_spend']}, "
      f"channels={', '.join(version['budget']['spend_channels'])}")

# โ”€โ”€ Step 3: Download the daily budget CSV โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
csv_resp = check(requests.get(
    f"{BASE_PATH}/plans/{plan['id']}/versions/{version_id}/budget",
    headers={**HEADERS, "Accept": "text/csv"},
))
df = pd.read_csv(io.StringIO(csv_resp.text))
df.to_csv("plan_budget.csv", index=False)
print(f"Saved plan_budget.csv ({len(df)} rows)")

# โ”€โ”€ Step 4 (optional): Raise Meta's spend by 10% as a new version โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# The budget is applied as a patch, so send only the column you are changing โ€”
# every other channel, date and contextual variable is inherited unchanged.
budget_table = [["date", "meta"]] + [
    [str(d), str(v * 1.10)] for d, v in zip(df["date"], df["meta"])
]

new_version = check(requests.post(
    f"{BASE_PATH}/plans/{plan['id']}/versions",
    headers={**HEADERS, "Content-Type": "application/json"},
    json={
        "base_version_id": version_id,  # must be the CURRENT primary version, or you get a 409
        "form": {"budget": budget_table},
    },
), expected=201).json()

print(f"Created version {new_version['id']}, now the primary version")