Oil & Gas Well Permits Search & Monitor API (ND/CO) avatar

Oil & Gas Well Permits Search & Monitor API (ND/CO)

Pricing

from $4.40 / 1,000 results

Go to Apify Store
Oil & Gas Well Permits Search & Monitor API (ND/CO)

Oil & Gas Well Permits Search & Monitor API (ND/CO)

Search and monitor US oil & gas well permits and wells from official state ArcGIS sources (North Dakota / Bakken, Colorado). Filter by state, operator, status, well type and county, and track newly issued drilling permits for lead gen. Keyless, official sources.

Pricing

from $4.40 / 1,000 results

Rating

0.0

(0)

Developer

Kyle Maloney

Kyle Maloney

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

0

Monthly active users

7 days ago

Last modified

Share

Search and monitor US oil and gas wells and permitted drilling locations straight from the official state regulator ArcGIS services for North Dakota (Bakken) and Colorado. Filter by state, operator, status, well type, county, name or API number; get one normalised row per well with coordinates and PLSS location, exportable as JSON, CSV or Excel. Keyless, official sources, no scraped middleman.

Built for oilfield-service sales teams working fresh drilling activity by county, mineral-rights and royalty buyers tracking who is drilling on which section-township-range, midstream and siting analysts sizing gathering and water-disposal demand, ESG and academic researchers needing a citable well inventory, land brokers resolving an operator or API number to a location, and AI agents needing a strictly-typed permit-search tool.

Every count below was re-read from the two live services with returnCountOnly on 2026-08-25, against build 1.0.11. Every field name came out of the shipped dataset schema.


Breaking changes in build 1.0.11 - read this if you already consume this Actor

Three changes alter values you may be parsing. Each replaces a confident wrong answer.

1. api_number is now null on every Colorado row. Colorado's locations layer publishes fac_num, a pad or aliquot designator, not an API well number. Measured over all 84,927 CO locations: 5,559 values contain a space, 1,589 contain "Pad", 765 (0.90%) are null and 2 are the literal string N/A. Values like 16 SWD Pad, 14NESW and S28-10D were being published in a field called api_number. The raw value is not discarded - it now ships in the new facility_number field, and api_number_status reads not_an_api_number, so "the state published nothing" and "the state published something that is not an API number" stay distinguishable. Validation is by shape, not by state, so ND's api_no is checked on every row too.

The apiNumber INPUT is unaffected. A Colorado lookup still queries fac_num, so {"states":["CO"],"apiNumber":"4-8"} matches exactly what it always did. Rejecting a value for output must not silently disable the filter.

2. Colorado record_id moved from fac_num to loc_id. fac_num is not a key: 220 Colorado locations share the literal value Pad, 8 share 1-28, and 765 are null. As a delta identity that silently suppressed 219 of those 220 forever. loc_id is non-null on all 84,927 rows. If you joined on Colorado record_id, rekey on loc_id - which is also what api_id carries on Colorado rows.

3. A run whose date window no selected state can honour now FAILS. A Colorado-only run with sinceModified previously returned the entire unfiltered Colorado universe up to maxResults, labelled as filtered, and billed for it. It now stops before any fetch and bills nothing.

Also: all delta baselines reset once. monitor mode moved to a named, query-scoped key-value store, so the first scheduled run after this build has no prior baseline for its scope, emits everything, and says so on every row via delta_baseline_established_this_run: true.


Scope and cost - read this first

states is REQUIRED

{ "states": ["ND"], "maxResults": 500 }

A call that omits states is rejected before the run starts - HTTP 400, no run object created, nothing billed, not even the Actor-start event. That is deliberate: the field used to carry a schema default, and Apify injects defaults server-side, so a caller who named no state was quietly answered about one arbitrary state, told the run SUCCEEDED, and billed.

One caveat: an explicitly empty array ({"states": []}) is not the same as omitting the field. The key is present, so the platform accepts it and the code falls back to every configured state - ND and CO together. Name your states.

What a run costs

Price
List price (Free plan)$0.008 per result = $8.00 per 1,000 rows ($0.80 per 100)
Bronze / Silver / Gold / Platinum / Diamond$0.0064 / $0.0056 / $0.0044 / $0.0032 / $0.0024 per result (20-70% off)
Actor start$0.00005, one-time per GB of run memory

Only rows written to the dataset are billed; a run that fails a pre-flight check emits nothing and bills nothing.

maxResults is the only ceiling - set it

It defaults to 1000 and applies PER STATE, so a two-state default run can emit 2,000 rows (about $16.00). The schema allows up to 200,000 per state, where the real ceiling becomes the data: 43,835 ND wells ($350.68) plus 84,927 CO locations ($679.42) is 128,762 rows, about $1,030.10. The default caps a single-state run at $8.00, but that number is published here rather than left for you to find on an invoice.

Row counts for realistic scopes

Measured live with returnCountOnly - true matching-record counts, not estimates.

InputRowsAt list price
{"states":["ND"],"sinceModified":"2026-08-01"} (spudded in ~3 weeks)52$0.42
{"states":["ND"],"sinceModified":"2026-01-01"} (spudded YTD)427$3.42
{"states":["ND"],"status":"LOC"} (permitted, not yet drilled)630$5.04
{"states":["ND"],"sinceModified":"2026-01-01","includeUndatedRecords":true}7,415$59.32
{"states":["CO"],"operator":"chevron"}203$1.62
{"states":["ND"],"operator":"continental"}3,618$28.94
{"states":["CO"],"county":"weld"}26,967$215.74

The pattern: a date or status filter is cheap, a bare county is not.


Coverage

Two states, each read live at run time from the regulator's own public ArcGIS FeatureServer - no key, no login, no cached copy.

StateRegulatorLayerRecordsDate field?
NDNorth Dakota DMR / NDICOilGasPublicMapDataVectorTiles/Wells/FeatureServer/0 on gis.dmr.nd.gov43,835 wellsSPUD_DATE only - not a permit-issue date
COColorado ECMC (formerly COGCC)DNR_Public/OGCC_Oil_and_Gas_Locations/FeatureServer/0 on data.dnrgis.state.co.us84,927 locationsNo

Freshness is whatever the state last published. Neither layer exposes an editingInfo.lastEditDate, so this Actor makes no update-cadence claim. What can be shown is a measurement: on 2026-08-25 the newest North Dakota spud_date in the layer was 2026-08-17, eight days old.

What is NOT covered - plainly

Only North Dakota and Colorado. No nationwide mode, no hidden "all states" behaviour. Absent in particular: Texas (RRC), New Mexico (OCD), Oklahoma (OCC), Pennsylvania, Ohio, West Virginia, Wyoming, Montana, Utah, Alaska, Louisiana, Kansas, California and every other state. For California the regulator is CalGEM and a third-party Actor, scrapesignal_labs/calgem-well-permit-leads, covers it - a neutral pointer, not an endorsement; it has not been tested against this one. Also out of scope by design: production volumes, completions, well logs, permits still in application, lease/unit ownership, and operator contact details.


Input reference

From the shipped input_schema.json.

PropertyTypeRequiredDefaultBehaviour
statesarrayYesnone (prefill ["ND"])Two-letter codes. Configured: ND, CO. Case-insensitive; unknown codes dropped.
operatorstringNononeOperator name contains, case-insensitive. E.g. continental.
statusstringNononeExact status code, case-insensitive. Vocabularies below. Empty = all.
wellTypestringNononeWell type contains. ND only - CO's layer has no well-type field.
countystringNononeCounty name contains. E.g. mckenzie, weld.
nameContainsstringNononeWell or facility name contains.
apiNumberstringNononeExact identifier. ND matches api_no (dashed, e.g. 33-053-10805-00-00); CO matches fac_num (e.g. 4-8). CO still searches fac_num even though api_number now publishes null on CO.
sinceModifiedstringNononeSPUD date lower bound, NOT a permit-issue date - read the section below before using it for permit monitoring. Must be YYYY-MM-DD. ND only; a CO-only run with this set FAILS. Pattern: ^$|^\d{4}-\d{1,2}-\d{1,2}$.
includeUndatedRecordsbooleanNofalseOnly meaningful with sinceModified. Keeps records whose date is null instead of dropping them - on ND that is the 630 permitted-but-not-yet-drilled wells. Each is labelled date_filter_match: "undated_included".
maxResultsintegerNo1000Max rows per state. Min 1, max 200000. The emit cap, therefore the billing cap.
monitorbooleanNofalseDelta mode, backed by a named, query-scoped key-value store.
verifyUpstreambooleanNotrueRuns the live drift gate against each selected state layer before any billable row is fetched. See below. Turning it off means a silently emptied or re-scoped layer would go undetected; every row then reads drift_gate_status: "skipped".
simulateOutagestringNo"none"Diagnostic. Forces one class of drift-probe outcome so the gate's own failure branches can be re-proved on demand instead of only during a real outage. See the table below.

sinceModified must be YYYY-MM-DD, and a bad value fails the run

Accepted: 2026-01-15, and unpadded 2026-1-5 (padded for you). Empty or omitted means no date filter. Anything else fails the run before any network request, and bills nothing.

Why so strict: the value is spliced into an ArcGIS SQL clause as spud_date > DATE '<value>', and ArcGIS's own date parser is lenient - it reinterprets a malformed value instead of rejecting it, and answers HTTP 200 with nothing indicating a guess. Measured live against ND's Wells layer:

You sendWhat ArcGIS silently didWells matched
2025-01-15 (the contract)read as written1,268
05/06/2025read as US May 61,027 - the EU June-5 reading is 945, an 82-well 8.0% swing, invisible in the output
15/01/2025swapped day and month rather than rejecting month 151,268 - right by luck

Six measured shapes are silently reinterpreted; only three (2025, 2025-13-45, 2025-02-30) fail loudly at ArcGIS. So all slash forms are rejected on purpose, even ones ArcGIS resolves correctly today: guessing trades a loud, free, fixable error for a silent, billed, wrong one. Validation runs in two layers - the schema pattern rejects prose at the platform edge before a run exists, and the code then rejects impossible calendar dates like 2026-02-30, which a regex cannot express.

Rejection happens before the per-state loop, so it fires whichever states you picked, including a Colorado-only run. Colorado has no date field, but "no column to reject it on" is not "your date was fine".

sinceModified is a SPUD date, not a permit-issue date - and the row says so

This is the single most important caveat in the product, so it ships as data rather than only as prose. Neither configured state publishes a permit-ISSUE date this Actor can filter on, established by enumerating both regulators' entire public catalogues, not by assumption.

North Dakota - no permit-issue date exists. The Wells layer publishes exactly one date field, SPUD_DATE. The only other permit-flavoured service in NDIC's 60-service OilGasPublicMapDataVectorTiles folder, PermitStatusBeforeSpud (252 features), carries Api / FileNo / Operator / DayRange / TimesRenew / Lat / Lon and no date field at all - re-read field by field from the layer metadata. Measured live:

QueryWells
status = 'LOC' (permitted, not yet drilled)630
...of those, with a non-null spud date0
spud_date > 2026-01-01427
spud_date > 2026-01-01 AND status = 'LOC'0

Because SQL treats NULL > DATE '...' as false, a date window structurally excludes every permitted-but-not-yet-drilled location - exactly the set a "new permits" buyer is looking for.

Colorado - a permit-approval date exists, and is deliberately NOT used. ECMC does publish Perm_Appr on a separate OGCC_Permits layer. It is a stale mirror, measured 2026-08-25: 914 features, newest approval 2025-02-26 (18 months old), Perm_Appr > 2026-01-01 returns 0, and 315 of the 914 permits have already expired. Sourcing from it would answer "permits approved since 2026-01-01" with a green, billed, confident 0 drawn from a frozen snapshot. A stale mirror that answers is more dangerous than a source that admits it cannot - and no layer in that family exposes editingInfo.lastEditDate, so there is no currency signal to gate it on.

What the Actor does instead:

  • Every row publishes date_filter_basis, date_filter_applied and date_filter_is_permit_issue_date - false on North Dakota, meaning checked, and it is not the permit date; null where nothing was applied. date_basis_note carries the explanation on the row itself.
  • A run whose date window no selected state can honour FAILS and bills nothing.
  • includeUndatedRecords: true widens the clause to (spud_date > DATE '...' OR spud_date IS NULL) so the permitted inventory is reachable, each such row labelled undated_included so a record never verified to fall inside the window is never presented as though it had been. It widens the set: 6,988 of 43,835 ND wells carry a null spud date, so a 2026-01-01 window goes from 427 rows to 7,415.
  • For a clean permitted-inventory feed, {"states":["ND"],"status":"LOC"} remains the right query - 630 rows, no date filter needed.

Status vocabularies, measured in full

Read from the live layers with a group-by, so these are complete, not sampled.

ND status - 19 values summing exactly to 43,835: A active 20,722, PA 6,447, DRY 6,347, PNC 5,735, IA 1,538, Confidential 949, AB 841, LOC permitted location 630, DRL drilling 331, TA 173, then TAO 30, PANF 27, PNS 20, EXP 17, TASC 11, TATD 8, NC 6, LOCR 2, NJ 1.

ND well_type - 22 values summing exactly to 43,835, overwhelmingly OG 40,197 (91.7%).

CO fac_status - only three codes exist: CL 46,133 (54.32%), AC 28,657 (33.74%), AL 10,135 (11.93%). Those sum to 84,925, not 84,927: 2 Colorado locations carry a null fac_status and belong to no code at all, so a status filter can never reach them. A Colorado run with no status filter is a majority-CL result set.

Codes ship verbatim - a wrong expansion of a regulator's code is worse than the code.


The live drift gate

Every run checks the state layers against pinned, live-measured expectations before it fetches a single billable row. An offline test suite - this Actor has 495 of them - structurally cannot see a layer that has been emptied, truncated, re-scoped or re-vocabularied, because the test fixtures were captured from whatever the layer was serving the day they were written. The gate is what closes that hole. It costs 11 cheap requests for a two-state run (measured 3.5 s end to end on 2026-08-25); none of them pages and none of them is billable.

What it proves, and the numbers behind each check

ProbeRoleWhat it assertsMeasured 2026-08-25
layer_meta_<ST>load-bearingEvery raw column the mapper reads is still published, and the layer declares a usable maxRecordCount. The column list is derived from the same config mapWell uses, never hard-coded, so it cannot fall out of step.ND 17 required of 23 published; CO 13 of 19
row_count_floor_<ST>load-bearingThe layer still holds at least its floor of records.ND 43,835 (floor 30,000); CO 84,927 (floor 55,000)
positive_canary_<ST>load-bearingOne pinned record still resolves to exactly one row with its historical values intact. ND: API 33011000010000, a 1929 Bowman County gas well - api_no, County and the spud epoch are all facts that cannot legitimately change. CO: loc_id 149011, Garfield County.1 feature each, all pinned values matching
negative_control_<ST>corroborating role, load-bearing clauseAn impossible status code must return zero.ND and CO both 0
<field>_vocabulary_<ST>corroboratingThe status/type code set is intact: enough distinct codes, every high-population code still present, and the group-by still accounts for the whole layer.ND 19 statuses and 22 well types, each summing to 43,835; CO 3 statuses summing to 84,925 plus a 2-row null bucket = 84,927

Why the floor alone is not enough. A floor passes happily while the layer serves the right number of wrong rows. The positive canary is the clause that says the rows are still ours.

Why the negative control matters. A service that stopped applying the where clause would return the full table for every query - which sails past the floor, sails past the vocabulary check, and hands every buyer an unfiltered dump wearing their own filter label. If the impossible status ever matches the whole layer, the run says exactly that and stops.

Colorado's null bucket is the trap worth naming. The three named fac_status codes sum to 84,925 against a layer total of 84,927. An "the codes must sum to the total" assertion written the obvious way would be red on a perfectly healthy layer. The gate counts the null bucket, and the sum is compared with a small tolerance rather than exactly, because the count and the group-by are two separate requests and Colorado can gain a location between them.

A new code is news, not a failure. A status or well-type code the layer publishes today that was not in the 2026-08-25 vocabulary is surfaced on every row in drift_unmapped_status_codes rather than silently bucketed into something it is not. A core code disappearing - LOC, the permitted-inventory code this product sells on - is a different matter and does fail the run.

pass / fail / unavailable - never two values

Every probe has its own error boundary and one of three outcomes. This is not stylistic. A sibling Actor once wrapped four probes in a single try/catch; three of them passed with good measurements, the fourth hit a gateway timeout, and the shared catch collapsed all three passing results into an empty object and hard-failed a completely healthy Actor.

OutcomeMeaningEffect
passCompleted, every clause held.Proceed.
failCompleted, and measured something wrong. Real drift.Run fails, nothing emitted, nothing billed - whatever the probe's role.
unavailableNever completed - a 5xx, a transport failure, an unparseable body. This says nothing about the data.Fatal only if the probe was load-bearing (or if every positive canary was unreachable). Otherwise the run proceeds and says so.

Both state services answer an unknown column with HTTP 200 carrying an embedded {"error":{"code":400}} - measured on both layers. That is an answer, and it is exactly the answer a renamed column produces, so it is classified as drift and fails the run. A 5xx or a dropped connection is not.

When only corroborating probes were unreachable the run proceeds with drift_gate_status: "verified_degraded", and drift_probes_unavailable names precisely which check was skipped. unavailable means NOT RUN. It never means the check failed, and it is never quietly folded into a pass.

Every row says what was actually proved

FieldMeaning
drift_gate_statusverified, verified_degraded, or skipped (you turned verifyUpstream off). Never failed - a failing gate stops the run before anything is emitted.
drift_negative_control_statuspass / unavailable / skipped for this row's own state. A Colorado row never inherits North Dakota's result.
drift_probes_verifiedNames of the probes that completed and passed.
drift_probes_unavailableNames of corroborating probes that could not be run. Empty means everything answered; null means the gate did not run at all.
drift_unmapped_status_codesCodes seen upstream today that were not in the measured vocabulary. [] = confirmed, nothing new. null = never confirmed.
drift_gate_measuredEvery value the gate actually measured, keyed by probe. Published so a future tightening of the bands is evidence-based rather than guessed.
drift_gate_noteOne paragraph stating what was and was not confirmed.

Re-proving the gate on demand

simulateOutage forces one class of probe outcome so the failure branches can be exercised without waiting for a real outage.

ModeForcesExpected result
nonenothingnormal operation
drift_required_unavailableload-bearing probes to time outrun FAILS, nothing billed
drift_positive_unavailableevery positive canary to time outrun FAILS, nothing billed
drift_corroborating_unavailablecross-checks to time outrun SUCCEEDS, drift_gate_status: "verified_degraded"
drift_measured_wronga probe to complete and measure wronglyrun FAILS through the drift path, not the outage path

A simulated outage is routed through the same error classifier a real 503 takes, so it proves the real code path rather than a parallel one.


Output fields

One row per well or location. All 48 fields are declared in the dataset schema and all nullable - a field the state does not publish comes back null, never omitted, guessed or zero-filled. Twenty-two shipped through build 1.0.10; 19 were appended in 1.0.11 and 7 more by the live drift gate. None have ever been removed or renamed. Fields marked NEW are the 1.0.11 nineteen; the seven marked DRIFT are the gate's.

FieldTypeMeaning
statestringTwo-letter state code the record came from.
state_namestringFull state name.
api_numberstringAPI well number, only when the published value is actually shaped like one (compact 10/12/14 digits, or SS-CCC-NNNNN with optional -DD-EE). null on every Colorado row.
api_number_statusstringNEW. Why api_number holds what it holds: valid, not_an_api_number (every CO row - raw value in facility_number), or not_published.
facility_numberstringNEW. The state's facility/pad designator verbatim - CO fac_num, e.g. 16 SWD Pad. null on ND, which publishes none. Not unique, not a key: 220 CO locations share the value Pad.
api_idstringCompact 14-digit API number (ND) or internal loc_id (CO).
well_namestringWell name (ND) or fac_name (CO).
operatorstringOperator / company on record.
statusstringState status code, verbatim.
well_typestringWell type code. null on every CO row.
countystringCounty the well is in.
field_namestringOil and gas field name. null on every CO row.
latitudenumberSurface latitude, WGS84.
longitudenumberSurface longitude, WGS84.
total_depthnumberMeasured depth, feet. null on every CO row; null on 18.0% of ND wells.
file_numberstringState file number (ND) or operator number (CO).
spud_datestringSpud date, YYYY-MM-DD. null on every CO row; null on 15.9% of ND wells.
sectionnumber or stringPLSS section.
townshipstringPLSS township.
rangestringPLSS range.
quarter_quarterstringPLSS quarter-quarter aliquot.
record_idstringPer-state id used for delta dedup: ND api, CO loc_id. Was CO fac_num through 1.0.10 - see breaking changes.
date_filter_requestedbooleanNEW. Whether the caller supplied sinceModified.
date_filter_appliedbooleanNEW. Whether the window was applied to this state. false = the state publishes no date column. null = no window requested.
date_filter_basisstringNEW. spud_date / unavailable / none_requested.
date_filter_is_permit_issue_datebooleanNEW. false on North Dakota - checked, and it is not the permit-issue date. null where nothing was applied.
date_filter_matchstringNEW. in_window (the row's own date was verified past the bound) or undated_included (kept only by includeUndatedRecords, not verified in-window). null when no window applied.
date_basis_notestringNEW. One sentence stating what this state's date can and cannot tell you.
state_statusstringNEW. ok / failed for the state this row came from. Always ok on an emitted row, because a failed state contributes none - read states_failed instead.
states_requestedarrayNEW. Every state the run attempted.
states_okarrayNEW. States whose query completed.
states_failedarrayNEW. States that errored and contributed zero rows. Non-empty means this dataset is PARTIAL.
results_truncatedbooleanNEW. More matched than maxResults allowed. null = the matching total could not be read, which is not "not truncated".
state_matched_totalnumberNEW. Exact matching-record count from the service's own returnCountOnly. null when unreadable, never 0.
state_returned_countnumberNEW. Records fetched for this state, before delta suppression.
max_resultsnumberNEW. The per-state emit cap this run used - i.e. the billing cap.
delta_modebooleanNEW. Whether monitor was set.
delta_baseline_keystringNEW. Key in the named store oil-gas-well-permits-delta: seen_ plus the first 40 hex characters of a SHA-256 over the canonical query scope. null outside delta mode.
delta_baseline_established_this_runbooleanNEW. true when no baseline existed for this scope, so everything was emitted and billed as new.
drift_gate_statusstringDRIFT. verified / verified_degraded / skipped. Never failed - a failing gate stops the run before anything is emitted or billed.
drift_negative_control_statusstringDRIFT. pass / unavailable / skipped for this row's own state. A CO row never inherits ND's result.
drift_probes_verifiedarrayDRIFT. Probes that completed and passed before this row was fetched.
drift_probes_unavailablearrayDRIFT. Corroborating probes that could not be run. [] = everything answered. null = the gate did not run.
drift_unmapped_status_codesarrayDRIFT. Status/type codes the layer publishes today that were not in the 2026-08-25 vocabulary. [] = confirmed, nothing new. null = never confirmed.
drift_gate_measuredobjectDRIFT. Every value the gate measured this run, keyed by probe - layer totals, field counts, canary attributes, the negative-control count, vocabulary sums.
drift_gate_notestringDRIFT. One paragraph stating what was and was not confirmed upstream.
source_systemstringAuthoritative source, e.g. North Dakota O&G (ND).
source_urlstringLink to the state oil and gas data portal.

Measured null rates, so you know before you buy rather than after:

  • Coordinates are complete - 0 of 43,835 ND wells and 0 of 84,927 CO locations are missing latitude or longitude. Colorado's four null columns above mean "not published" - not zero, not no.
  • Colorado publishes no API number on this layer, and the row now says so - the measurements are in the breaking-changes section.
  • record_id on Colorado is non-null on all 84,927 rows (loc_id IS NULL returns 0). Uniqueness is not something this service can be asked: the layer silently ignores returnDistinctValues=true - it answered 84,927 for a distinct count of fac_num while fac_num = 'Pad' alone returns 220. So loc_id is published as a non-null identifier (observed range 149,011-489,594), not as a proven-unique key.
  • ND publishes confidential wells in the open - 949 (2.17%) carry the string Confidential in both status and well_type.

Worked examples

1. New drilling activity in North Dakota

The published example task on the Store listing.

{ "states": ["ND"], "maxResults": 500 }

Rows are ordered by spud_date descending for any state with a date field, so the newest activity is at the top. A real row (abridged; every row carries all 41 fields):

{
"state": "ND", "api_number": "33-061-05459-00-00",
"api_number_status": "valid", "facility_number": null,
"well_name": "EN-MOMM- 157-93-1705H-4", "operator": "HESS BAKKEN INVESTMENTS II, LLC",
"status": "DRL", "well_type": "OG", "county": "MOUNTRAIL", "field_name": "BIG BUTTE",
"latitude": 48.41742781, "longitude": -102.72098017,
"total_depth": null, "spud_date": "2026-08-17",
"section": 17, "township": "157", "range": "93", "quarter_quarter": "SWSE",
"date_filter_basis": "none_requested", "date_filter_is_permit_issue_date": null,
"states_ok": ["ND"], "states_failed": [], "results_truncated": true,
"state_matched_total": 43835, "state_returned_count": 500, "max_results": 500
}

Note total_depth: null on a well still drilling - no depth is published yet, and the row says so rather than printing 0. Note also results_truncated: true beside state_matched_total: 43835: a 500-row cap against a 43,835-row universe is a truncated answer, and the row admits it.

2. One county, one date bound, cheaply

{ "states": ["ND"], "county": "mckenzie", "sinceModified": "2026-08-01", "maxResults": 200 }

20 matching wells, about $0.16 - the date bound takes McKenzie County from 10,976 rows to 20. Every row carries date_filter_is_permit_issue_date: false: these are wells drilled in the window, not permits issued in it.

{ "states": ["CO"], "operator": "chevron", "maxResults": 250 }

203 matching locations, about $1.62. A real CO row (abridged):

{
"state": "CO", "api_number": null, "api_number_status": "not_an_api_number",
"facility_number": "16 SWD Pad", "api_id": "159595",
"well_name": "Wildhorse", "operator": "TALLGRASS WATER WESTERN LLC",
"status": "AC", "well_type": null, "county": "WELD", "field_name": null,
"latitude": 40.749417, "longitude": -103.991839,
"total_depth": null, "spud_date": null, "record_id": "159595",
"date_filter_applied": null, "date_filter_basis": "none_requested",
"date_filter_is_permit_issue_date": null,
"states_ok": ["CO"], "states_failed": [], "results_truncated": false
}

That row illustrates the Colorado caveats: four null fields the layer does not carry, and an identifier that is a pad label - surfaced as facility_number with api_number: null and a status saying why, rather than published under a name it does not deserve.


Failure behaviour

The rule: a run must never bill for an answer it did not verify.

Fails loudly, bills nothing:

  • No states key - platform HTTP 400, no run object created.
  • No recognisable state code, e.g. ["ZZ"].
  • A malformed sinceModified - rejected before the per-state loop and before any network request, for every state selection including CO-only.
  • A date window no selected state can honour, e.g.
    {"states":["CO"], "sinceModified":"2026-01-01"}
    - stops before any fetch.
  • Every selected state failing upstream - stops before any push.
  • The live drift gate measuring something wrong - a layer below its record floor, a column the mapper reads gone, the pinned canary record missing or changed, an impossible status matching any rows, or a status vocabulary that collapsed or lost a core code. Stops before any fetch of billable rows.
  • A load-bearing drift probe that could not be reached at all - the layer could not be confirmed, so nothing is sold. A merely corroborating probe being unreachable does not stop the run; it degrades it, and every row says so.
  • An unrecognised simulateOutage value.

Null versus false is a contract. null means the state does not publish that field, or the check was never made - never "no", never "zero".

Partial results are visible on the row, not buried in the log. If ND answers and CO errors, every emitted row carries states_requested: ["ND","CO"], states_ok: ["ND"] and states_failed: ["CO"], so the absence of Colorado wells is never mistaken for "Colorado has none". A partial run still succeeds - one good state is a real answer - but it is labelled as partial on every row.

Truncation is measured, not guessed. Before fetching, each state's exact matching count is read from the service's own returnCountOnly - free, and both services support it with an arbitrary filter. Every row then carries state_matched_total, state_returned_count, max_results and results_truncated. If the count itself cannot be read, results_truncated is null, never false - not checked is not the same as not truncated.

monitor: true carries state across runs. The seen-ID baseline lives in a named key-value store (oil-gas-well-permits-delta) under a key derived from the whole query scope - states, every filter, includeUndatedRecords and maxResults - so a narrowly filtered run cannot overwrite a broad run's baseline. The key is a SHA-256 hash of that canonical scope, not a truncated encoding of it: a base64url prefix was tried first and collided, because two queries sharing an early field share their leading bytes, so a narrow scope silently adopted a broad scope's baseline. This Actor's own offline suite caught it.

The baseline is written only after the all-failed gate passes and the rows are actually emitted, so a failed run cannot advance it and silently hide those wells from the next run. The first run for a given scope has nothing to diff against and emits everything, saying so on every row via delta_baseline_established_this_run: true.


Limitations and honest boundaries

  • A permit or well record is a filing, not a drilled hole. status: "LOC" is a permitted location the state approved - not evidence anything was or will be drilled. 630 ND wells sit in LOC today.
  • No permit-issue date exists for North Dakota. The full enumeration is under Input reference. sinceModified is a spud window; every row states that in date_filter_is_permit_issue_date, and {"states":["ND"],"status":"LOC"} is the query for permitted inventory.
  • Colorado's permits layer is stale and deliberately unused. ECMC's Perm_Appr exists but its newest approval is 2025-02-26 and it returns 0 for anything since 2026-01-01. This Actor will not read a frozen source to manufacture a date filter.
  • No date filtering is possible for Colorado at all - the locations layer has no date column. A Colorado run with no date window returns the current snapshot; a Colorado-only run with a date window fails rather than quietly returning that snapshot labelled as filtered.
  • There is no live drift gate on this Actor. Field names, status vocabularies and record counts are asserted against fixtures captured from the live services, but nothing probes the upstream for a silent schema change before billing. If a state renames a column, the affected fields go null and the run still succeeds. Read state_matched_total and spot-check a field you depend on if you schedule this.
  • Colorado rows are locations, not wells - a location may cover a pad with several wellbores.
  • Coordinates are surface locations - neither layer describes a horizontal bottomhole, which in the Bakken can be two miles from the surface hole.
  • Delta baselines are capped at 200,000 ids per state. No configured scope comes near that today (Colorado's whole universe is 84,927), but a scope that exceeded it would drop its oldest ids and re-emit those records once.
  • Status codes are not harmonised across states, and freshness is the state's, not ours. Counts here are dated 2026-08-25 and the layers move daily.

Use as an MCP tool

Callable by any MCP-compatible AI agent through Apify's hosted MCP server - nothing to run, no integration code. The tool schema an agent sees is generated from this Actor's own schemas, so the strict YYYY-MM-DD date contract binds the agent too. Endpoint: https://mcp.apify.com?tools=malonestar/oil-gas-well-permits

Claude Desktop, Claude Code or Cursor - add to claude_desktop_config.json, .mcp.json or .cursor\mcp.json respectively:

{
"mcpServers": {
"apify": {
"url": "https://mcp.apify.com?tools=malonestar/oil-gas-well-permits",
"headers": { "Authorization": "Bearer YOUR_APIFY_TOKEN" }
}
}
}

Token: https://console.apify.com/settings/integrations (Claude Desktop can also use OAuth with no headers block). Docs: https://docs.apify.com/platform/integrations/mcp

Try asking your agent

List every North Dakota well spudded in McKenzie County since 2026-08-01 with the operator and section-township-range, and tell me which operator has the most.

An agent reading a row finds date_filter_is_permit_issue_date: false on that answer, which is the field that stops it reporting drilled wells as new permits.

Chains well with - comma-separate tools and the agent can carry a well row's coordinates into the next screener: ...?tools=malonestar/oil-gas-well-permits,malonestar/ntad-gas-pipeline-proximity-screener,malonestar/orphaned-well-proximity-screener

Billing is unchanged as an MCP tool: an agent pays the same per-result price a human does, and a run that cannot answer fails without billing.


FAQ

Why did my run fail with a date error? sinceModified accepts YYYY-MM-DD only. Slash forms are rejected on purpose, even ones that look unambiguous, because ArcGIS silently reinterprets them - 05/06/2025 measured an 8.0% swing in matching wells depending on the order the parser guessed. The rejection costs nothing: it happens before any fetch.

Why did my Colorado-only run with a date fail? Colorado's layer has no date column, so the window could not be applied to any state you selected. Returning the whole unfiltered Colorado snapshot labelled as filtered - what builds through 1.0.10 did - is a wrong answer that bills. Add ND, or drop sinceModified.

Colorado api_number is suddenly null. Where did it go? It was never an API number. Colorado publishes fac_num, a pad designator like 16 SWD Pad, and it now ships in facility_number with api_number_status: "not_an_api_number". The apiNumber input still searches that same column, so lookups are unaffected.

Can I track newly issued drilling permits? Yes, but not with sinceModified - and the Actor tells you that on every row. It filters on spud date, so it finds newly drilled wells, not newly permitted ones: all 630 LOC wells have a null spud date, so a spud_date > 2026-01-01 query returns 427 wells of which zero are permits. Neither state publishes a filterable permit-issue date at all. Use {"states":["ND"],"status":"LOC","monitor":true} on a schedule - the delta store is named and query-scoped, so each run emits only the permits that appeared since the last run with the same inputs. Or set includeUndatedRecords: true alongside sinceModified to keep the undated permits in a windowed result set, labelled date_filter_match: "undated_included".

My monitor run re-emitted everything. Why? Check delta_baseline_established_this_run. If it is true, no baseline existed for that exact query scope - either it is the first run, or a filter changed, which forks the key by design. Every baseline also reset once at build 1.0.11, when the store moved from the per-run default store to a named one.

My run returned fewer rows than I expected. Was it truncated? Read results_truncated. true means more matched than maxResults allowed, and state_matched_total gives the true figure. false means the set is complete. null means the matching count could not be read - not checked, which is not the same as not truncated.

A state is missing from my results. Does it have no wells? Check states_failed. If the state is listed there, its query errored and contributed zero rows - that is not evidence it has nothing. If every selected state fails, the run fails outright and bills nothing.

What is the most a run can cost me? maxResults (default 1000, per state) times $0.008 per row, minus your plan discount. A single-state default run tops out at $8.00. At the 200,000 maximum across both states the whole ND+CO universe is 128,762 rows, about $1,030 at list price - so set maxResults.


All verified live as published before being linked here.

Pricing

Pay-Per-Result, $8.00 per 1,000 rows list, 20-70% off on paid plans. Full breakdown under Scope and cost above.