KotobaMedia Tile Kiln

API documentation

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

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.