KotobaMedia Tile Kiln exposes a small HTTP API for automating the uploader workflow: create a job, upload a GeoTIFF to the returned presigned URL, poll until conversion finishes, then use the generated TileJSON URL. Creating a job requires a contact email or valid API key, but public job responses do not expose contact email or API-key metadata. Jobs created with a change key can also have the upload and generated output deleted by the caller before expiry.
Base URLs
| Environment | Base URL |
|---|---|
| Production | https://tile-kiln-api.kmproj.com |
| Development | https://tile-kiln-api-dev.kmproj.com |
API_URL="https://tile-kiln-api.kmproj.com"
Limits and timing
- Input files must be GeoTIFF-compatible TIFF or BigTIFF files.
- Input uploads are limited to
100000000bytes. POST /jobsrequires a contact email for internal tracing or a valid API key. Job responses do not return the email or API-key metadata.- Include
sizeBytes,fileSizeBytes, orcontentLengthwhen creating a job. - Presigned upload URLs currently expire after 900 seconds.
- Source GeoTIFF objects are deleted after processing reaches
COMPLETEDorFAILED. - Job status may stop being available seven days after processing finishes.
- Uploads without a
changeKeyexpire one calendar month after the upload is received; jobs that never upload expire one calendar month after job creation. - Uploads with a
changeKeyexpire six calendar months after the upload is received and can be deleted by the caller before expiry; jobs that never upload expire six calendar months after job creation. - Uploads created with a valid API key do not expire automatically.
- Expired uploads and generated outputs are cleaned up automatically.
- The upload request must use the exact
Content-Typereturned byupload.headers.
1. Create a job
POST /jobs
curl -sS -X POST "$API_URL/jobs" \
-H "Content-Type: application/json" \
-d '{
"email": "maps@example.com",
"changeKey": "keep-this-to-delete-later",
"fileName": "source.tif",
"sizeBytes": 73400320,
"contentType": "image/tiff",
"options": {
"tileFormat": "png",
"tileSize": 512,
"resampling": "bilinear",
"minZoom": 4,
"maxZoom": 14
}
}'
For an API-key upload, send the key as an x-api-key header. apiKey or api_key in the JSON body is also accepted. When a valid API key is supplied, email is optional; if omitted, the contact email associated with the API key is used.
curl -sS -X POST "$API_URL/jobs" \
-H "Content-Type: application/json" \
-H "x-api-key: $TILE_KILN_API_KEY" \
-d '{
"fileName": "source.tif",
"sizeBytes": 73400320,
"contentType": "image/tiff"
}'
Request body fields
| Field | Required | Description |
|---|---|---|
email |
Yes unless using an API key | Contact email for internal tracing. contactEmail is also accepted. For API-key uploads, this may be omitted to use the contact email associated with the API key, or supplied to override it for that job. The email is not returned by POST /jobs or GET /jobs/{jobId} job responses. |
fileName |
Yes | Source file name. filename is also accepted. Unsafe characters are sanitized. |
changeKey |
No | Optional caller-held key. For non-API-key uploads, this extends retention to six calendar months and lets DELETE /jobs/{jobId} remove the upload before expiry. For API-key uploads, retention remains indefinite; a change key only enables caller-requested delete. When omitted, caller-requested delete is disabled. change_key is also accepted. |
apiKey |
No | API key for indefinite-retention uploads. Prefer the x-api-key header; api_key is also accepted in the body. A valid API key makes the upload non-expiring and excluded from garbage collection. |
sizeBytes |
Recommended | Source file size in bytes. fileSizeBytes and contentLength are also accepted. |
contentType |
No | Upload content type. Defaults to image/tiff. |
options |
No | Tile conversion options. Options may also be sent as top-level fields when options is omitted. |
Conversion options
| Option | Default | Description |
|---|---|---|
tileFormat |
png |
Raster tile format: png, jpeg, or webp. |
tileSize |
256 |
Tile size in pixels. Integer from 128 to 4096. |
resampling |
bilinear |
nearest, bilinear, cubic, cubicspline, lanczos, mode, or average. |
minZoom |
unset | Optional minimum zoom. Integer from 0 to 24. |
maxZoom |
unset | Optional maximum zoom. Integer from 0 to 24. Must be at least minZoom. |
quality |
unset | Optional output quality. Integer from 1 to 100. |
overviewLevels |
[2,4,8,16,32,64,128,256] |
Non-empty array of powers of two greater than or equal to 2. |
Successful response
The public job object intentionally omits the contact email, change key, and API-key metadata.
In job responses, expiresAt is when the public job status is expected to expire. API-key jobs are non-expiring, so expiresAt is omitted.
{
"job": {
"jobId": "43f5a43d-3081-4378-9a19-7f4a47d0dc0b",
"status": "AWAITING_UPLOAD",
"fileName": "source.tif",
"canDelete": true,
"contentType": "image/tiff",
"createdAt": "2026-06-25T04:30:00.000Z",
"updatedAt": "2026-06-25T04:30:00.000Z",
"expiresAt": 1798173000,
"input": {
"bucket": "tile-kiln-input",
"key": "uploads/43f5a43d-3081-4378-9a19-7f4a47d0dc0b/source.tif",
"sizeBytes": 73400320
},
"output": {
"bucket": "km-tileserver",
"key": "uploads/raster/43f5a43d-3081-4378-9a19-7f4a47d0dc0b/source.pmtiles"
},
"options": {
"overviewLevels": [2, 4, 8, 16, 32, 64, 128, 256],
"resampling": "bilinear",
"tileFormat": "png",
"tileSize": 512,
"minZoom": 4,
"maxZoom": 14
}
},
"upload": {
"method": "PUT",
"url": "https://...",
"headers": {
"Content-Type": "image/tiff"
},
"maxSizeBytes": 100000000,
"expiresInSeconds": 900
}
}
2. Upload the GeoTIFF
Upload the source file directly to the returned presigned URL. Do not add authorization headers.
UPLOAD_URL="https://..."
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/tiff" \
--data-binary @source.tif
3. Poll the job
GET /jobs/{jobId}
JOB_ID="43f5a43d-3081-4378-9a19-7f4a47d0dc0b"
curl -sS "$API_URL/jobs/$JOB_ID"
Status values
| Status | Meaning |
|---|---|
AWAITING_UPLOAD |
The job exists, but the source object has not been uploaded yet. |
PROCESSING |
The upload event was received and conversion is running. |
COMPLETED |
The PMTiles archive was written successfully. |
FAILED |
Conversion failed. The job may include error and errorDetails. |
Poll every few seconds. Stop polling when the status is COMPLETED or FAILED.
4. Build the TileJSON URL
Completed output keys end in .pmtiles. The public TileJSON URL uses the same key without
.pmtiles, plus .json, under https://tiles.kmproj.com/.
OUTPUT_KEY="uploads/raster/43f5a43d-3081-4378-9a19-7f4a47d0dc0b/source.pmtiles"
TILEJSON_URL="https://tiles.kmproj.com/${OUTPUT_KEY%.pmtiles}.json"
echo "$TILEJSON_URL"
map.addSource("uploaded-raster", {
type: "raster",
url: "https://tiles.kmproj.com/uploads/raster/43f5a43d-3081-4378-9a19-7f4a47d0dc0b/source.json",
tileSize: 512,
});
5. Optional caller-requested delete
DELETE /jobs/{jobId}
Caller-requested delete is only available when the job was created with a non-empty changeKey. API-key authentication does not authorize deletion by itself. The change key is not returned by the API.
For completed conversions, this deletes the retained conversion state and generated PMTiles output. If temporary source or error state still exists, it is removed too. If conversion never completed, the endpoint removes the transient upload state.
If a job is actively PROCESSING, delete returns 409; retry after the job reaches COMPLETED or FAILED.
JOB_ID="43f5a43d-3081-4378-9a19-7f4a47d0dc0b"
curl -sS -X DELETE "$API_URL/jobs/$JOB_ID" \
-H "Content-Type: application/json" \
-d '{"changeKey":"keep-this-to-delete-later"}'
The change key can also be sent as an x-change-key header.
End-to-end shell script
This script uses jq and polls until the job finishes.
#!/usr/bin/env sh
set -eu
API_URL="${API_URL:-https://tile-kiln-api.kmproj.com}"
FILE_PATH="${1:?Usage: $0 source.tif maps@example.com}"
EMAIL="${2:?Usage: $0 source.tif maps@example.com}"
CHANGE_KEY="${CHANGE_KEY:-}"
FILE_NAME="$(basename "$FILE_PATH")"
SIZE_BYTES="$(wc -c < "$FILE_PATH" | tr -d ' ')"
CREATE_RESPONSE="$(
jq -n \
--arg email "$EMAIL" \
--arg changeKey "$CHANGE_KEY" \
--arg fileName "$FILE_NAME" \
--arg contentType "image/tiff" \
--argjson sizeBytes "$SIZE_BYTES" \
'({
email: $email,
fileName: $fileName,
sizeBytes: $sizeBytes,
contentType: $contentType,
options: {
tileFormat: "png",
tileSize: 512,
resampling: "bilinear"
}
} + (if $changeKey == "" then {} else { changeKey: $changeKey } end))' |
curl -sS -X POST "$API_URL/jobs" \
-H "Content-Type: application/json" \
--data-binary @-
)"
JOB_ID="$(printf '%s' "$CREATE_RESPONSE" | jq -r '.job.jobId')"
UPLOAD_URL="$(printf '%s' "$CREATE_RESPONSE" | jq -r '.upload.url')"
CONTENT_TYPE="$(printf '%s' "$CREATE_RESPONSE" | jq -r '.upload.headers["Content-Type"]')"
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: $CONTENT_TYPE" \
--data-binary "@$FILE_PATH"
while :; do
JOB_RESPONSE="$(curl -sS "$API_URL/jobs/$JOB_ID")"
STATUS="$(printf '%s' "$JOB_RESPONSE" | jq -r '.job.status')"
printf '%s\n' "job $JOB_ID: $STATUS"
case "$STATUS" in
COMPLETED)
OUTPUT_KEY="$(printf '%s' "$JOB_RESPONSE" | jq -r '.job.output.key')"
printf '%s\n' "https://tiles.kmproj.com/${OUTPUT_KEY%.pmtiles}.json"
exit 0
;;
FAILED)
printf '%s\n' "$JOB_RESPONSE" >&2
exit 1
;;
esac
sleep 3
done
Errors
Error responses use JSON:
{
"message": "fileName is required."
}
| HTTP status | Meaning |
|---|---|
400 |
Invalid JSON body, missing required fields, invalid email, invalid change key, or invalid conversion options. |
403 |
A supplied API key is invalid, or delete was requested without a stored change key or with the wrong change key. |
404 |
The route, job ID, or retained conversion was not found. |
409 |
Delete was requested while the upload is actively processing. |
413 |
The declared input size is larger than 100000000 bytes. The body also includes maxSizeBytes. |
500 |
Unexpected service error. |