Skip to main content

Type Curve 2.0 Migration Guide

Type Curve V1 retires on 2026-11-07

The Type Curve 2.0 switch-over happens on Saturday, 2026-11-07, and it is one-way. On that day the V2 endpoints under /v2/projects/{projectId}/type-curves are turned on, and every /v1/projects/{projectId}/type-curves endpoint, lookup tables included, stops responding at the same time. There is no going back to V1.

Build and review your changes against this guide and the Type Curves V2 (Preview) reference before then. V1 responses already carry the header Sunset: Sat, 07 Nov 2026 00:00:00 GMT.

What is affected​

Only type curves and type-curve lookup tables. Wells, forecasts, econ models (including risking models), econ runs, scenarios, productions, directional surveys, exports and tags are unaffected.

Type-curve ids do not change at the switch-over. Any type-curve id you store today is still valid in V2.

Start now​

You can build all of this today, against the V2 reference:

  1. Match type curves by id, not by name. V2 updates by id, and ids survive the switch-over.
  2. Split each create into separate calls: POST the type curve, then PUT its fits, then (if you normalize) PUT its normalization. See Creating a type curve.
  3. Rewrite fit payloads to the V2 shape: rename type to fitType; move basePhase (from the fit and from each ratio series) onto ratio, once; add regressionType (moved off the type curve) and eurPercentileMatched; and remove resolution, align, normalize, normalizations and any eur on a series. See Fits.
  4. Replace PUT with PATCH, keeping in mind that PATCH merges instead of replacing. See Updates merge instead of replace.
  5. Move volume reads from /fits/daily and /fits/monthly to /volumes?resolution=…, and let your parser tolerate missing keys. See Volumes.
  6. Replace /representative-wells reads with GET /{id}/wells, plus forecast outputs for the per-phase columns. See Representative wells are removed.
  7. Move lookup-table writes to the V2 routes and rule shape. See Lookup tables.

Segments need no change. Segment types, field names, units and date bounds are the same in V1 and V2, and V2 accepts every segment V1 accepted (plus the read-only fields it returns).

Endpoint mapping​

All paths are relative to /projects/{projectId}/type-curves, under /v1 or /v2.

V1V2
HEAD /HEAD /
GET /GET /
GET /headRemoved. Use HEAD /
GET /{id}GET /{id} (the response no longer embeds fits)
POST / (array, 207)POST /batch (array, 207), or POST / with one object (201); then PUT /{id}/fits and PUT /{id}/normalization
PUT / (array, replaces by name, 207)PATCH /batch (array, merges by id, 207), or PATCH /{id} with one object (200)
DELETE / (id / name filters)DELETE / (same filters), or DELETE /{id}
GET /{id}/fits/dailyGET /{id}/volumes?resolution=daily
GET /{id}/fits/monthlyGET /{id}/volumes?resolution=monthly
GET /{id}/representative-wellsRemoved. GET /{id}/wells, plus forecast outputs
—GET /{id}/fits, PUT /{id}/fits (new)
—GET /{id}/normalization, PUT /{id}/normalization (new)
/lookup-tables/…/lookup-tables/…, see Lookup tables

Changes that do not produce errors​

These three changes return a success status with different behavior, so a client that is not updated for them will not see an error. Check for them first.

Updates merge instead of replace​

V1 PUT / replaced each type curve matched by name, creating it when the name was not found: fields you left out were deleted, and a renamed type curve became a new, duplicate one. V2 PATCH merges, matched by id: fields you leave out are not changed. Both return a success status.

Two exceptions keep wells and excludedWells consistent: re-linking forecast without sending wells drops the wells the new forecast does not contain, and changing wells without sending excludedWells drops exclusions for wells no longer assigned. A null value is ignored, so it cannot be used to clear a field.

PATCH /{id} accepts an id in the body, which must match the path. PATCH /batch takes an array of objects, each with its id.

Fits and normalization are no longer part of the type-curve write​

In V1 a single POST created a type curve with its fits and their normalization. In V2 they are separate resources, each with its own GET and PUT. V2 rejects fits and regressionType on the type curve with a 400, so a V1 body copied as-is fails. Remove both and the POST returns 201 and stores a type curve without fits. It has its wells, but it produces no volumes until you PUT /{id}/fits.

Representative wells are removed​

V2 has no concept of a representative well: a well is either part of the type curve's fit or excluded from it, for every phase. GET /{id}/representative-wells has no V2 successor. Membership moves to GET /{id}/wells, and the per-phase columns move to forecast outputs:

V1 fieldV2 source
wellId, chosenID, api14, wellName, wellNumberGET /{id}/wells, where each row is the full project well object (id, not wellId)
rep (per phase)excluded on GET /{id}/wells, one value per well. rep: true is excluded: false
eur, forecastType, dataFrequencyGET /v1/projects/{projectId}/forecasts/{forecastId}/outputs?well=…&phase=…, using the type curve's forecast. Read eur from the series named by the type curve's forecastSeries, lower-cased (best.eur, p10.eur, …, or ratio.eur on a ratio forecast). Outputs eur is in BBL or MCF; V1's was in thousands (MBBL or MMCF), so divide by 1,000 to match V1. dataFrequency is data_freq. On a probabilistic forecast, outputs return forecastType: "rate" and the detail in forecastSubType
eurPlleur divided by perfLateralLength from the /wells row
valid, hasData, hasForecastRemoved
  • Membership is one value per well. V1 could mark a well representative for oil but not for gas. In V2 a well is in the fit or excluded from it for every phase.
  • V2 has no validation criteria. V1 left wells that failed the type curve's validation criteria (by default, a well needed both production and a forecast for the phase) out of the fit even when nobody had excluded them. V2 has no such setting: every assigned well not in excludedWells is in the fit, and a well only drops out of a phase when it has no production for that phase.
  • ?well= on forecast outputs takes up to 100 values, so a type curve with 2,000 wells takes 20 calls per phase.

The type curve​

V1:

{
"id": "5f971a8f6749f60012dcb93a",
"name": "TC1",
"forecast": "5e272bed4b97ed00132f2271",
"regressionType": "rate",
"wells": ["5e272d38b78910dd2a1bd691"],
"createdAt": "2026-08-01T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z",
"fits": { "oil": { "…": "…" }, "gas": { "…": "…" }, "water": { "…": "…" } }
}

V2:

{
"id": "5f971a8f6749f60012dcb93a",
"name": "TC1",
"project": "5e272bed4b97ed00132f0001",
"forecast": "5e272bed4b97ed00132f2271",
"forecastSeries": "best",
"wells": ["5e272d38b78910dd2a1bd691", "5e272d38b78910dd2a1bd692"],
"excludedWells": ["5e272d38b78910dd2a1bd692"],
"createdAt": "2026-08-01T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
}
FieldChange
projectNew, read-only, taken from the path
forecastSeriesNew: best, P10, P50 or P90. Anything other than best needs a probabilistic forecast
excludedWellsNew: wells assigned to the type curve but left out of its fit. Must be a subset of wells
regressionTypeMoved from the type curve to each phase of its fits
fitsMoved to GET /{id}/fits

Everything else keeps its name, type and meaning.

V2 never returns null: a field the type curve does not have is left out. If you round-trip documents, do not rely on every key being present.

Creating a type curve​

A V1 POST carrying fits and normalization becomes up to three V2 calls:

POST /v2/projects/{projectId}/type-curves                      → 201, the stored type curve
PUT /v2/projects/{projectId}/type-curves/{id}/fits → 200, the stored fits
PUT /v2/projects/{projectId}/type-curves/{id}/normalization → 200, the stored normalization
  • POST / and PATCH /{id} take one object and answer 201 / 200 with the stored document, or 400, 404 or 409 as plain statuses. POST /batch and PATCH /batch take arrays of up to 100 and answer 207 with a status per record, like V1.
  • An array sent to POST / or PATCH /{id} is a 400 pointing you to /batch.
  • wells and excludedWells each hold at most 2,000 wells.
  • wells require a forecast, and every well must belong to it. forecast must exist in the project. Facilities and well collections are rejected in wells.
  • A name already used in the project is a 409, on create and on rename.

Reading and deleting​

  • take defaults to 25, up to 2,500. Sort by id (the default, -id), name, createdAt or updatedAt.
  • GET / returns a Link header; the X-Query-Count header comes from HEAD /, as in V1.
  • DELETE / takes id and/or name filters (up to 100 values each, combined with OR) and answers 204 with X-Delete-Count, or 404 when nothing matches, as in V1. DELETE /{id} is new and answers 404 when the type curve is not in the project.
  • Deleting a type curve also deletes its fits and normalization, and unlinks lookup-table rules that point to it.

Fits​

GET /{id}/fits returns the fits keyed by phase. A phase without a fit is left out; a type curve that has never been fitted returns {}. ?phase= narrows the response and can be repeated.

A GET body is a valid PUT body. PUT /{id}/fits replaces the type curve's fits and answers 200 with the stored fits.

  • It is a full replace. Send every phase you want to keep: a stored phase missing from the body is deleted. Any non-empty subset of phases is accepted.
  • Removed fields are rejected, not ignored. V1 required resolution and normalizations on each phase, and V2 rejects both, so a V1 payload copied as-is fails.
  • fitType, regressionType and eurPercentileMatched are required on each phase.
  • A rate fit carries any of best / p10 / p50 / p90 (V1 required all four) and no ratio. A ratio fit carries ratio and none of the others.
  • A ratio fit's ratio.basePhase must name a different phase in the same body that is a rate fit. A ratio fit's eurPercentileMatched must be false.
V1 fits.{phase}V2
typefitType (rate or ratio)
best / p10 / p50 / p90Unchanged (rate fits only)
ratio.{best,p10,p50,p90}Unchanged
basePhase (on the fit, and on each ratio series)ratio.basePhase, one value per fit
—regressionType (rate or cum), moved from the type curve
—eurPercentileMatched: whether EUR-matched segments were kept over the raw regression
resolution, align, normalizeRemoved. V2 does not record what a fit was computed with
normalizationsMoved to /{id}/normalization, with a different shape
eur (on a series)Rejected. V1 accepted it but never returned it
—updatedAt per phase (read-only, ignored on PUT)

Before, a V1 fits entry (abbreviated: V1 also required a water fit, and all four series on every fit):

{
"oil": {
"type": "rate",
"resolution": "monthly",
"align": true,
"normalize": false,
"normalizations": { "normalizationType": "eur", "eur": { "type": "no_normalization" } },
"best": { "segments": [ { "segmentIndex": 1, "segmentType": "arps_modified", "…": "…" } ] },
"p10": { "segments": [ "…" ] },
"p50": { "segments": [ "…" ] },
"p90": { "segments": [ "…" ] }
},
"gas": {
"type": "ratio",
"resolution": "monthly",
"normalizations": { "normalizationType": "eur", "eur": { "type": "no_normalization" } },
"ratio": {
"best": { "basePhase": "oil", "segments": [ { "segmentIndex": 1, "segmentType": "flat", "…": "…" } ] },
"p10": { "basePhase": "oil", "segments": [ "…" ] },
"p50": { "basePhase": "oil", "segments": [ "…" ] },
"p90": { "basePhase": "oil", "segments": [ "…" ] }
}
}
}

After, the V2 PUT /{id}/fits body:

{
"oil": {
"fitType": "rate",
"regressionType": "rate",
"eurPercentileMatched": true,
"best": { "segments": [ { "segmentIndex": 1, "segmentType": "arps_modified", "…": "…" } ] }
},
"gas": {
"fitType": "ratio",
"regressionType": "rate",
"eurPercentileMatched": false,
"ratio": {
"basePhase": "oil",
"best": { "segments": [ { "segmentIndex": 1, "segmentType": "flat", "…": "…" } ] }
}
}
}

V2 accepts every segment field it returns, so a GET → PUT round trip works (in V1 it did not). One exception, unchanged from V1: on a linear segment, slope is the slope value on write but its sign (-1, 0 or 1) on read.

Normalization​

V1 returned normalization inside fits.{phase}.normalizations, as one fit per step, and left it out entirely when the EUR step was no_normalization. V2 stores one configuration per type curve covering all three phases, at GET / PUT /{id}/normalization, and each factor is a piecewise fit of up to 5 segments. Reading only segments[0] ignores the rest of the curve.

{
"oil": {
"xAxis": "perf_lateral_length",
"yAxis": "eur_and_peak",
"targets": [{ "key": "perf_lateral_length", "value": 10000 }],
"eur": {
"segments": [
{ "xStart": 0, "fitType": "linear", "aValue": 1.02, "bValue": 0 },
{ "xStart": 7500, "fitType": "power_law_fit", "aValue": 0.95, "bValue": 1.1 }
]
},
"peak": {
"segments": [{ "xStart": 0, "fitType": "power_law_fit", "aValue": 0.88, "bValue": 0.94 }]
}
},
"gas": { "…": "same shape" },
"water": { "…": "same shape" }
}
V1V2
normalizationType: "eur"yAxis: "eur" with eur.segments
normalizationType: "eur_and_q_peak"yAxis: "eur_and_peak" with eur.segments and peak.segments. Both factors share one xAxis, and customBasisKey is not allowed, so a V1 config whose eur.base was not eur_pll has no direct equivalent
eur.typesegments[].fitType: linear (y = aValue·x + bValue), power_law_fit (y = aValue·x^bValue) or 1_to_1
eur.slope / eur.intercept (linear)aValue / bValue
eur.coefficient / eur.exponent (power law)aValue / bValue
eur.type: "no_normalization"eur: { "segments": [] }
peak.*peak.segments[], same rules
eur.target / peak.target (header → value), and the read-only perfLateralLengthtargets: [{ "key": "<header>", "value": … }]: one list per phase, shared by both factors, up to 3 targets. With customBasisKey, the one target's key is the basis's composite ratio (for example first_prop_weight/perf_lateral_length)
eur.base: "eur_pll"xAxis: "perf_lateral_length", yAxis: "eur"
peak.base: "peak_pll" (the only base V1 accepted on peak)The peak factor of yAxis: "eur_and_peak", with xAxis: "perf_lateral_length"
eur.base: "eur_vs_numerical"xAxis: the selectedNumericalTarget header, yAxis: "eur", plus a targets entry for that header
eur.base: one of the six compound bases (prop/pll_eur/pll, …)customBasisKey, same value, with a single-factor yAxis such as "eur"
—yAxis: "peak": peak alone (new)
—yAxis: "eur_per_pll": EUR per foot of lateral against the xAxis header (new)
—linkedTo: the phase uses another phase's per-well multipliers (new)

Writing normalization:

  • Send all three phases. Unlike fits, normalization is always a whole-document replace.
  • A GET body is a valid PUT body, except with customBasisKey: then xAxis is returned as the basis's composite ratio, which cannot be written. Drop xAxis from the body before writing.
  • customBasisKey cannot be combined with yAxis: "eur_and_peak".
  • Segments' xStart values must be strictly increasing. A segment runs up to the next segment's xStart; the last one is unbounded.
  • Leaving out linkedTo removes a stored link, because the write replaces the whole document. A phase cannot link to itself or to a phase that is itself linked.
  • Per-well multipliers are not exposed, as in V1. Writing a configuration never recomputes them: a phase the new configuration leaves empty (no segments, or linked to such a phase) has its stored multipliers deleted, and every other phase keeps the multipliers from its last apply in the ComboCurve app until it is applied there again.
Normalization can differ from V1

Even with an equivalent configuration, the app can fit different coefficients and apply different per-well multipliers in V2 than in V1:

  • Peak is not smoothed. V1 took peak rates from a 30-day moving average; V2 uses the raw values.
  • One data frequency per phase. V1 chose daily or monthly data per well. In V2 a well with no data at the phase's frequency is left unnormalized.
  • Manual exclusions apply to production data only. Forecast volumes always count toward each well's EUR.

Volumes​

/fits/daily and /fits/monthly become GET /{id}/volumes?resolution=daily|monthly.

  • resolution is required. Leaving it out, or sending another value, is a 400.

  • Rows keep the V1 shape:

    [
    {
    "date": "2026-01-01",
    "oil": { "best": 504, "p10": 504, "p50": 504, "p90": 504 },
    "gas": { "best": 100, "p10": 222, "p50": 333, "p90": 4444 },
    "water": { "best": 7854, "p10": 6523, "p50": 98, "p90": 32 }
    }
    ]
  • A series without a fit is left out, where V1 returned it as null, and a phase with no fitted series is left out entirely, where V1 returned an object of four nulls. Parsers that expect every key must tolerate a missing one.

  • Paging is skip / take only: take defaults to 25, up to 2,500. The response has a Link header and no count header.

Lookup tables​

/v1/projects/{projectId}/type-curves/lookup-tables moves to /v2/projects/{projectId}/type-curves/lookup-tables. Record limits, filter operators and the document around the rules (name, caseInsensitiveMatching, tags and the rest) are unchanged, except that a table now holds at most 2,000 rules.

V1V2
HEAD /, GET /, GET /{id}Unchanged
GET /headRemoved. Use HEAD /
POST / (array, 207)POST /batch (array, 207), or POST / with one table (201)
PUT / (array, upsert by name, 207)PUT /batch (array, upsert by name, 207), or PUT /{id} to replace one table by id, including a rename (200). A body id that differs from the path is rejected

In the 207 responses from /batch, a record that fails on a name collision now reports 409, and one that names a missing type curve reports 404. V1 reported every failed record as 400. | DELETE /{id} | Unchanged: 204 with X-Delete-Count, which is 0 when nothing matched |

Rule changes:

V1 rule fieldV2
filter, typeCurve, applySeries, fixedDateUnchanged
fpdSourceUnchanged, except schedule, which is removed. Pick another source, or fixed with fixedDate
riskFactorOil / riskFactorGas / riskFactorWaterRenamed riskFactorPeakOil / riskFactorPeakGas / riskFactorPeakWater, same meaning. Must now be 0 or more (V1 had no minimum)
applyNormalization: truenormalization: { "source": "own" }
applyNormalization: false, or absentLeave normalization out
—normalization: { "source": "shared", "preset": "<id>" } (new)
phase, resolutionRemoved. V2 rules apply to every phase
  • Removed and renamed fields are rejected by name, never silently dropped, so an old riskFactorOil cannot quietly run a type curve unrisked.
  • V2 checks that each rule's typeCurve exists as a type curve in the project; V1 accepted any well-formed id. A rule that fails with "No type curve was found" names an id that is not a type curve in this project.
  • A rule with no filter, or an empty one, is stored and read back as [].