If you want to build this imagery into a JavaScript application, you can use the Processing API with the SkySat Sandbox Data collection ID: `BYOC-fc704520-fc81-439f-9016-5e162c32e736`. In the Processing API request, you can use the true color evalscript. You can get help with API requests for you application from our requests builder tool or by asking our AI assistant in our documentation. You may also need to use the Catalog API to find data where there is imagery available.
Here is some example code that works - you can expand the code in the hidden cell below - there is a server.py and index.html file
"""
Tiny local server: OAuth (client credentials), Catalog distinct dates, Process API map tiles.
Run from repo root: python server.py
Requires SH_CLIENT_ID and SH_CLIENT_SECRET in .env
"""
import json
import math
import os
import time
from datetime import datetime, timedelta, timezone
import requests
from dotenv import load_dotenv
from flask import Flask, Response, jsonify, request, send_from_directory
load_dotenv()
BASE = os.environ.get("SH_BASE", "https://services.sentinel-hub.com").rstrip("/")
TOKEN_URL = f"{BASE}/auth/realms/main/protocol/openid-connect/token"
CATALOG_SEARCH = f"{BASE}/api/v1/catalog/1.0.0/search"
PROCESS_URL = f"{BASE}/api/v1/process"
BYOC_TYPE = "byoc-fc704520-fc81-439f-9016-5e162c32e736"
COLLECTION = "byoc-fc704520-fc81-439f-9016-5e162c32e736"
AOI = {
"type": "Polygon",
"coordinates": [
[
[-46.480481779234, -23.611431091069008],
[-46.480481779234, -23.613647803458363],
[-46.47597059136797, -23.613647803458363],
[-46.47597059136797, -23.611431091069008],
[-46.480481779234, -23.611431091069008],
]
],
}
EVALSCRIPT = """//VERSION=3
function setup() {
return {
input: [{ bands: ["blue", "red", "green", "dataMask"] }],
output: { bands: 4 },
};
}
var f = 2.5 / 10000;
function evaluatePixel(sample) {
return [sample.red * f, sample.green * f, sample.blue * f, sample.dataMask];
}
"""
app = Flask(__name__, static_folder="static", static_url_path="")
_token = None
_token_expires_at = 0.0
def get_token():
global _token, _token_expires_at
if _token and time.time() < _token_expires_at - 60:
return _token
cid = os.environ.get("SH_CLIENT_ID", "").strip()
csec = os.environ.get("SH_CLIENT_SECRET", "").strip()
if not cid or not csec:
raise RuntimeError("Set SH_CLIENT_ID and SH_CLIENT_SECRET in .env")
r = requests.post(
TOKEN_URL,
data={
"grant_type": "client_credentials",
"client_id": cid,
"client_secret": csec,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=60,
)
r.raise_for_status()
data = r.json()
_token = data["access_token"]
# JWT exp claim optional; fall back to expires_in
exp = data.get("expires_in", 3600)
_token_expires_at = time.time() + float(exp)
return _token
def tile_lonlat_bounds(z: int, x: int, y: int):
"""Web Mercator XYZ tile corners in WGS84 (min_lon, min_lat, max_lon, max_lat)."""
n = 2.0**z
min_lon = x / n * 360.0 - 180.0
max_lon = (x + 1) / n * 360.0 - 180.0
max_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
min_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return (min_lon, min_lat, max_lon, max_lat)
def _iso_date_prefix(value):
if value is None:
return None
s = str(value).strip()
return s[:10] if len(s) >= 10 else None
def _dates_from_catalog_features(features):
"""Catalog distinct 'features' may be GeoJSON features or plain date strings."""
out = []
for feat in features:
if isinstance(feat, str):
d = _iso_date_prefix(feat)
if d:
out.append(d)
continue
if not isinstance(feat, dict):
continue
props = feat.get("properties") or {}
dt = props.get("datetime") or props.get("start_datetime")
if not dt:
dt = feat.get("datetime")
d = _iso_date_prefix(dt)
if d:
out.append(d)
return out
def _collect_dates_paginated(token, page_limit=100, max_pages=50):
"""Fallback: paginate STAC search and unique dates (if distinct shape differs)."""
seen = set()
next_token = None
for _ in range(max_pages):
body = {
"collections": [COLLECTION],
"datetime": "2021-01-01T00:00:00Z/2022-12-31T23:59:59Z",
"intersects": AOI,
"limit": page_limit,
"fields": {"include": ["properties.datetime"]},
}
if next_token is not None:
body["next"] = next_token
r = requests.post(
CATALOG_SEARCH,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=body,
timeout=120,
)
r.raise_for_status()
payload = r.json()
for d in _dates_from_catalog_features(payload.get("features") or []):
seen.add(d)
ctx = payload.get("context") or {}
next_token = ctx.get("next")
if next_token is None:
break
return list(seen)
def day_range_utc(day_str: str):
"""ISO date YYYY-MM-DD -> (from, to) for that UTC calendar day."""
d = datetime.strptime(day_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
start = d
end = d + timedelta(days=1) - timedelta(seconds=1)
return (
start.strftime("%Y-%m-%dT%H:%M:%SZ"),
end.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
@app.get("/")
def index():
return send_from_directory(app.static_folder, "index.html")
@app.get("/api/dates")
def api_dates():
try:
token = get_token()
except Exception as e:
return jsonify({"error": str(e)}), 500
dates = []
next_token = None
for _ in range(50):
body = {
"collections": [COLLECTION],
"datetime": "2021-01-01T00:00:00Z/2022-12-31T23:59:59Z",
"intersects": AOI,
"distinct": "date",
"limit": 100,
}
if next_token is not None:
body["next"] = next_token
r = requests.post(
CATALOG_SEARCH,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=body,
timeout=120,
)
if not r.ok:
return (
jsonify({"error": r.text, "status": r.status_code}),
r.status_code,
)
payload = r.json()
dates.extend(_dates_from_catalog_features(payload.get("features") or []))
ctx = payload.get("context") or {}
next_token = ctx.get("next")
if next_token is None:
break
dates = list(set(dates))
if not dates:
dates = _collect_dates_paginated(token)
dates = sorted(set(dates))
return jsonify({"dates": dates})
@app.get("/tiles/<int:z>/<int:x>/<int:y>.png")
def tile(z, x, y):
day = request.args.get("date")
if not day:
return Response("missing date", status=400)
try:
token = get_token()
t_from, t_to = day_range_utc(day)
except Exception as e:
return Response(str(e), status=500)
min_lon, min_lat, max_lon, max_lat = tile_lonlat_bounds(z, x, y)
req_body = {
"input": {
"bounds": {
"bbox": [min_lon, min_lat, max_lon, max_lat],
"properties": {
"crs": "http://www.opengis.net/def/crs/EPSG/0/4326",
},
},
"data": [
{
"type": BYOC_TYPE,
"dataFilter": {
"timeRange": {"from": t_from, "to": t_to},
},
}
],
},
"output": {
"width": 256,
"height": 256,
"responses": [
{
"identifier": "default",
"format": {"type": "image/png"},
}
],
},
"evalscript": EVALSCRIPT,
}
r = requests.post(
PROCESS_URL,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "image/png",
},
data=json.dumps(req_body),
timeout=120,
)
if not r.ok:
return Response(r.text, status=r.status_code, mimetype="text/plain")
return Response(r.content, mimetype="image/png")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SkySat sandbox viewer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; }
#bar {
padding: 8px 12px;
border-bottom: 1px solid #ccc;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
#map { height: calc(100vh - 49px); width: 100%; }
label { font-size: 14px; }
select { min-width: 200px; padding: 4px 8px; }
#status { font-size: 13px; color: #666; }
.err { color: #a00; }
</style>
</head>
<body>
<div id="bar">
<label for="dates">Acquisition date</label>
<select id="dates" disabled></select>
<span id="status">Loading dates…</span>
</div>
<div id="map"></div>
<script>
const aoi = {
type: "Polygon",
coordinates: [[
[-46.480481779234, -23.611431091069008],
[-46.480481779234, -23.613647803458363],
[-46.47597059136797, -23.613647803458363],
[-46.47597059136797, -23.611431091069008],
[-46.480481779234, -23.611431091069008],
]],
};
const ring = aoi.coordinates[0];
const lats = ring.map((c) => c[1]);
const lons = ring.map((c) => c[0]);
const center = [
(Math.min(...lats) + Math.max(...lats)) / 2,
(Math.min(...lons) + Math.max(...lons)) / 2,
];
const map = L.map("map").setView(center, 17);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap",
}).addTo(map);
L.geoJSON(aoi, {
style: { color: "#e91e63", weight: 2, fillOpacity: 0.05 },
}).addTo(map);
map.fitBounds(
[[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]],
{ padding: [24, 24] },
);
let overlay = null;
const sel = document.getElementById("dates");
const statusEl = document.getElementById("status");
function setOverlay(dateStr) {
if (overlay) map.removeLayer(overlay);
if (!dateStr) return;
overlay = L.tileLayer(
`/tiles/{z}/{x}/{y}.png?date=${encodeURIComponent(dateStr)}`,
{ tms: false, maxZoom: 20, opacity: 1, zIndex: 10 },
);
overlay.addTo(map);
}
sel.addEventListener("change", () => {
setOverlay(sel.value);
});
fetch("/api/dates")
.then((r) => r.json())
.then((data) => {
if (data.error) {
statusEl.textContent = data.error;
statusEl.classList.add("err");
return;
}
const dates = data.dates || [];
if (dates.length === 0) {
statusEl.textContent = "No dates returned (check collection / AOI).";
statusEl.classList.add("err");
return;
}
statusEl.textContent = dates.length + " date(s)";
sel.innerHTML = dates
.map((d) => `<option value="${d}">${d}</option>`)
.join("");
sel.disabled = false;
sel.value = dates[dates.length - 1];
setOverlay(sel.value);
})
.catch((e) => {
statusEl.textContent = String(e);
statusEl.classList.add("err");
});
</script>
</body>
</html>
If you decide to purchase SkySat data, you’ll then have access to order imagery yourself, and you would use functions similar to what you shared above using the Data API to search, and the Orders API to use the imagery.