Type Curve 2.0 Migration Guide
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:
- Match type curves by
id, not byname. V2 updates by id, and ids survive the switch-over. - Split each create into separate calls:
POSTthe type curve, thenPUTits fits, then (if you normalize)PUTits normalization. See Creating a type curve. - Rewrite fit payloads to the V2 shape: rename
typetofitType; movebasePhase(from the fit and from each ratio series) ontoratio, once; addregressionType(moved off the type curve) andeurPercentileMatched; and removeresolution,align,normalize,normalizationsand anyeuron a series. See Fits. - Replace
PUTwithPATCH, keeping in mind thatPATCHmerges instead of replacing. See Updates merge instead of replace. - Move volume reads from
/fits/dailyand/fits/monthlyto/volumes?resolution=…, and let your parser tolerate missing keys. See Volumes. - Replace
/representative-wellsreads withGET /{id}/wells, plus forecast outputs for the per-phase columns. See Representative wells are removed. - 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.
| V1 | V2 |
|---|---|
HEAD / | HEAD / |
GET / | GET / |
GET /head | Removed. 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/daily | GET /{id}/volumes?resolution=daily |
GET /{id}/fits/monthly | GET /{id}/volumes?resolution=monthly |
GET /{id}/representative-wells | Removed. 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 field | V2 source |
|---|---|
wellId, chosenID, api14, wellName, wellNumber | GET /{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, dataFrequency | GET /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 |
eurPll | eur divided by perfLateralLength from the /wells row |
valid, hasData, hasForecast | Removed |
- 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
excludedWellsis 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"
}
| Field | Change |
|---|---|
project | New, read-only, taken from the path |
forecastSeries | New: best, P10, P50 or P90. Anything other than best needs a probabilistic forecast |
excludedWells | New: wells assigned to the type curve but left out of its fit. Must be a subset of wells |
regressionType | Moved from the type curve to each phase of its fits |
fits | Moved 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 /andPATCH /{id}take one object and answer201/200with the stored document, or400,404or409as plain statuses.POST /batchandPATCH /batchtake arrays of up to 100 and answer207with a status per record, like V1.- An array sent to
POST /orPATCH /{id}is a400pointing you to/batch. wellsandexcludedWellseach hold at most 2,000 wells.wellsrequire aforecast, and every well must belong to it.forecastmust exist in the project. Facilities and well collections are rejected inwells.- A
namealready used in the project is a409, on create and on rename.
Reading and deleting
takedefaults to 25, up to 2,500. Sort byid(the default,-id),name,createdAtorupdatedAt.GET /returns aLinkheader; theX-Query-Countheader comes fromHEAD /, as in V1.DELETE /takesidand/ornamefilters (up to 100 values each, combined with OR) and answers204withX-Delete-Count, or404when nothing matches, as in V1.DELETE /{id}is new and answers404when 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
resolutionandnormalizationson each phase, and V2 rejects both, so a V1 payload copied as-is fails. fitType,regressionTypeandeurPercentileMatchedare required on each phase.- A
ratefit carries any ofbest/p10/p50/p90(V1 required all four) and noratio. Aratiofit carriesratioand none of the others. - A ratio fit's
ratio.basePhasemust name a different phase in the same body that is aratefit. A ratio fit'seurPercentileMatchedmust befalse.
V1 fits.{phase} | V2 |
|---|---|
type | fitType (rate or ratio) |
best / p10 / p50 / p90 | Unchanged (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, normalize | Removed. V2 does not record what a fit was computed with |
normalizations | Moved 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" }
}
| V1 | V2 |
|---|---|
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.type | segments[].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 perfLateralLength | targets: [{ "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
GETbody is a validPUTbody, except withcustomBasisKey: thenxAxisis returned as the basis's composite ratio, which cannot be written. DropxAxisfrom the body before writing. customBasisKeycannot be combined withyAxis: "eur_and_peak".- Segments'
xStartvalues must be strictly increasing. A segment runs up to the next segment'sxStart; the last one is unbounded. - Leaving out
linkedToremoves 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.
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.
-
resolutionis required. Leaving it out, or sending another value, is a400. -
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 fournulls. Parsers that expect every key must tolerate a missing one. -
Paging is
skip/takeonly:takedefaults to 25, up to 2,500. The response has aLinkheader 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.
| V1 | V2 |
|---|---|
HEAD /, GET /, GET /{id} | Unchanged |
GET /head | Removed. 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 field | V2 |
|---|---|
filter, typeCurve, applySeries, fixedDate | Unchanged |
fpdSource | Unchanged, except schedule, which is removed. Pick another source, or fixed with fixedDate |
riskFactorOil / riskFactorGas / riskFactorWater | Renamed riskFactorPeakOil / riskFactorPeakGas / riskFactorPeakWater, same meaning. Must now be 0 or more (V1 had no minimum) |
applyNormalization: true | normalization: { "source": "own" } |
applyNormalization: false, or absent | Leave normalization out |
| — | normalization: { "source": "shared", "preset": "<id>" } (new) |
phase, resolution | Removed. V2 rules apply to every phase |
- Removed and renamed fields are rejected by name, never silently dropped, so an old
riskFactorOilcannot quietly run a type curve unrisked. - V2 checks that each rule's
typeCurveexists 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[].