KotobaMedia Tile Kiln exposes a small HTTP API for automating the uploader workflow: create a job, upload a GeoTIFF with a single PUT or multipart session, 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 with at least three raster bands.
- Input uploads are limited to
1000000000bytes (decimal 1 GB). 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. It is required for multipart, and any declared size must match the completed object. - Presigned upload URLs expire after 3600 seconds. Selected multipart part URLs can be refreshed.
- Incomplete S3 multipart sessions are aborted automatically after one day.
- 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. |
uploadMode |
No | single (default) or multipart. Multipart requires sizeBytes. |
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 |
0 |
Optional minimum zoom. Integer from 0 to 24; defaults to 0. |
maxZoom |
unset | Optional maximum zoom. Integer from 0 to 24; when omitted, rio-pmtiles derives it from source resolution. Must be at least minZoom. |
quality |
unset | Optional JPEG or WebP output quality. Integer from 1 to 100; ignored for PNG. |
overviewLevels |
[2,4,8,16,32,64,128,256] |
Deprecated. Retained for backward compatibility but ignored; direct PMTiles output includes every zoom in the requested range. |
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",
"uploadMode": "single",
"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": {
"mode": "single",
"method": "PUT",
"url": "https://...",
"headers": {
"Content-Type": "image/tiff"
},
"maxSizeBytes": 1000000000,
"expiresInSeconds": 3600
}
}
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
Multipart upload
Set uploadMode to multipart and supply sizeBytes. The response contains a private bearer-capability uploadId, a 64 MiB partSizeBytes, and one signed URL per expected part. GET /jobs/{jobId} never returns the upload ID.
{
"upload": {
"mode": "multipart",
"uploadId": "private-capability",
"partSizeBytes": 67108864,
"expiresInSeconds": 3600,
"maxSizeBytes": 1000000000,
"parts": [
{ "partNumber": 1, "url": "https://..." },
{ "partNumber": 2, "url": "https://..." }
]
}
}
Slice the file without buffering it, PUT up to four parts concurrently, and retain every response ETag. On 401 or 403, send {"uploadId":"...","partNumbers":[3,4]} to POST /jobs/{jobId}/upload/parts.
After all parts succeed, sort the ETags and send {"uploadId":"...","parts":[{"partNumber":1,"etag":"..."}]} to POST /jobs/{jobId}/upload/complete before polling. Cancel with DELETE /jobs/{jobId}/upload and the upload ID in the JSON body.
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 or multipart uploadId 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 |
The multipart operation does not match the job state, or delete was requested while processing. |
413 |
The declared input size is larger than 1000000000 bytes. The body also includes maxSizeBytes. |
500 |
Unexpected service error. |