API contract — keepix.cloud Skip to content

API contract

API contract

Three spaces with physically separate entry points: public delivery with no API at all, the tenant's private API, and an admin API for the control panel. The answer is always JSON, and always in English — one wording per refusal code, so the same failure reads the same in every log.

Public delivery — no API at all

The file is looked up on disk by the web server. Not found means 404, not “generate it on the fly”.

GET https://img.example.com/i/<variant path>
GET https://v.keepix.cloud/v/<path>/master.m3u8

The delivery headers are the same for every tenant and do not change:

cache-control: public, max-age=31536000, immutable
x-content-type-options: nosniff
content-security-policy: default-src 'none'; sandbox
cross-origin-resource-policy: cross-origin
access-control-allow-origin: *

Not a single Set-Cookie: the delivery host has neither sessions nor JavaScript.

Signing

Every private-API request is signed. Four headers:

X-Media-Tenant: <tenant code>
X-Media-Key-Id: <key identifier>
X-Media-Ts:     <unix time, seconds>
X-Media-Sign:   hex(HMAC-SHA256(secret, METHOD "\n" PATH "\n" TS "\n" sha256(body)))

PATH is the path together with the query string (/api/v1/put?kind=prodimg&key=42). Otherwise the parameters would go unsigned and nobody would notice kind or key being swapped. The body of an empty request is hashed as an empty string (e3b0c442…), not as the literal "".

The window is 300 seconds. The secret is an ASCII string of 64 hexadecimal characters; sign with it as it is, there is nothing to decode.

A repeat is signed anew. The time in the signature is in seconds, so two identical requests in the same second produce a byte-identical signature, and the second one gets 409 sign_replay. That is replay protection, and it works as intended. Two rules follow: do not send identical signed requests in parallel, and when repeating after a broken connection, recompute the signature with a fresh X-Media-Ts.

A minimal signing client

#!/bin/bash
METHOD=$1; PATHQ=$2; BODY=$3
TS=$(date +%s)
BH=$( [ -f "$BODY" ] && sha256sum "$BODY" | cut -d' ' -f1 \
                     || printf '' | sha256sum | cut -d' ' -f1 )
SIG=$(printf '%s\n%s\n%s\n%s' "$METHOD" "$PATHQ" "$TS" "$BH" \
      | openssl dgst -sha256 -hmac "$KP_SECRET" -hex | sed 's/.*= //')
curl -H "X-Media-Tenant: $KP_TENANT" -H "X-Media-Key-Id: $KP_KEYID" \
     -H "X-Media-Ts: $TS"           -H "X-Media-Sign: $SIG" \
     --data-binary @"$BODY" -X "$METHOD" "https://keepix.cloud$PATHQ"

Endpoints

EndpointWhat it does
POST /api/v1/put Accept the original. The parameters kind, key, variants, owner_ref go in the query string; the body is the file itself. Images are ready in the answer, video goes into the queue. Idempotent: the same sha256 under the same key does not redo the work and returns the same addresses.
POST /api/v1/put-variant Accept an already finished variant with no processing — for moving over what you have. Re-encoding a finished copy a second time means losing quality for nothing. The intake checks are exactly the same.
GET /api/v1/resolve What addresses this key has. This is a repair: if your record is lost, you ask rather than guess.
POST /api/v1/resolve-batch The same for up to 1000 keys at a time. Body: {"kind":"prodimg","keys":["1","2"]}. The answer carries found and missing.
POST /api/v1/reconcile A dry check: did everything arrive. Changes nothing. Up to 1000 records at a time — checking a migration one file at a time would mean as many requests as there are files. Body: {"kind":"prodimg","items":[{"key":"1","variant":"card","bytes":23438,"sha256":"…"}],"only":"problems"}. The bytes and sha256 fields are optional: without them the service simply reports what it has; with them it compares. For each record the answer gives a status: ok, missing, mismatch, pending, unverified — plus a digest, a fingerprint of the whole batch. See Reconciling.
GET /api/v1/inventory Export our picture in batches: ?kind=prodimg&limit=1000&after=<key>. Returns keys with their variants (name, size, hash, address) in ascending key order; next is the cursor for the following page. Needed when the comparison runs the other way — our list against yours. The cursor is the key itself, so the page does not slide if something is accepted mid-walk.
DELETE /api/v1/object Remove an object whole: rows and bytes on disk. Stock-linked objects return 409 and retain their bytes.
POST /api/v1/erase Erase all non-stock uploads under an owner_ref when the person deletes their account. The answer names a number of objects erased: your personal-data gate checks the execution, not the intent.
Stock exception: If any object under this owner_ref is linked to a Keepix stock item, including a withdrawn item, erase returns 409 asset_in_stock before deleting anything. The original must stay available for existing licences. Keep stock and ordinary uploads under different owner_ref values; account closure needs a separate review.
GET /api/v1/quota How much is used, how much is left, how much is queued.
GET /api/v1/pingA check that the key is in use.
GET /healthz Unsigned. Says the front end is alive: the service name, the node, the version, free space and the time. Nothing about the database and nothing about tenants — node health is polled through this same answer.

Reconciling: making sure everything arrived

After a large migration there is one question that matters: did it all get through. Answering it one file at a time means making as many requests as there are files. So reconciliation goes in batches and changes nothing: it is a dry run.

How it is done

  1. Cut your list into batches of 1000 and send each to POST /api/v1/reconcile with your sizes and hashes.
  2. Look at summary: if ok equals the number of records, the batch matched and there is no point reading the rest.
  3. If it did not match, items holds what exactly and why. With "only":"problems" the matching ones are not returned at all: the answer to a successful batch of a thousand records is about 270 bytes.

What each answer means

statuswhat happened
okIt is there, and the size and hash match. If you sent neither a size nor a hash, ok only means “it is there”.
missingThe service has no such key (or no such variant of it). This is what needs sending again.
mismatchIt is there, but not the same: why says whether the size or the hash differs.
pendingThe object was accepted, the variant is still being made — video in the queue. There is nothing to resend, only to wait.
unverifiedIt is there, but there is nothing to compare your hash against. This happens with files accepted before 4 September 2026.

The batch fingerprint

The answer carries a digest — a sha256 over the lines kind|key|variant|bytes|sha256, sorted as strings and joined with \n. Compute it on your side the same way: if it matches, the whole batch matched and there is no need to walk a thousand answers. The order of records in the request does not affect the fingerprint.

Reconciling does not count as an operation and does not spend the package. Checking that your files are where you left them is something you should be able to do without glancing at the bill.

What exactly is compared

The service strips EXIF and GPS from everything it accepts. So the file we hold is not byte-identical to the one sent, if it carried metadata. The comparison therefore runs against what you sent: the size and hash are remembered before the cleaning. In the answer bytes and sha256 are what was sent, and stored_bytes is what lies on disk.

A caveat for older files: for those accepted before 4 September 2026, 02:54, the hash of what was sent was not recorded and has been computed from the stored copy after the fact. If such a file carried metadata, our hash is the hash of the cleaned copy, and the reconciliation will show mismatch quite legitimately. It is easy to check: send the file again, intake is idempotent.

Refusals

A refusal comes with the reason in words and a machine code. The reason does not retell a library exception: the machine string is read by the operator in the log, not by you in the answer.

{ "ok": false, "error": { "code": "sign_stale",
    "message": "The signature is older than 300 s (clocks differ by 4000 s). Check the clock on your machine." } }
CodeHTTPWhen
Signature
sign_missing401no signing headers
sign_bad_ts401X-Media-Ts is not a number of unix seconds
sign_stale401the signature is older than 300 seconds — check the clock
key_unknown401the key was not found or is retired
sign_bad403the signature does not match; most often the query string was not signed along with the path
sign_replay409this signature has been used — recompute with a fresh X-Media-Ts
What was sent
body_empty422the body is empty: the service expects the file itself
body_truncated400the body did not arrive whole — a transfer break, send it again
too_large413the file is larger than your ceiling
svg_forbidden415an SVG was sent — never accepted and never served
type_rejected415the type is not accepted; the answer says what can be seen in the bytes
file_truncated422the file is cut short: the length does not match the header. That is a break, not a bad picture
file_broken422the header looks like a picture, but it cannot be parsed as one
unreadable422the file does not parse as a picture
infected422the file was rejected by the virus scanner
What was asked for
no_route404 No such endpoint. The method and path are named in the refusal text — a typo is visible at once.
key_too_long422 The key is longer than 190 bytes. The key is your identifier, not a file name.
variant_empty422 No variant was named, and the kind has no default variant.
too_many_keys422 More than 1000 keys in resolve-batch.
args_missing422kind, key or variant is missing
kind_unknown422that kind is not allowed for you; the answer lists the allowed ones
variant_unknown422the service has no such variant — the set is named and finite
variant_not_allowed422the variant exists but is not set up for this kind
not_found404the service has no such object
asset_in_stock409an object under this owner_ref is linked to stock; erase deletes nothing, even when the item is withdrawn
asset_busy503another operation is changing this asset; no bytes were deleted. Retry after a short delay with a fresh signature
Ceilings and money
plan_required402this is not included in your plan
wallet_empty402the wallet is in the red: intake is closed, delivery works
rate_limited429too many uploads per minute
quota_exceeded507your storage ceiling is used up
Reconciling
body_not_json422 The reconciliation request body is not a JSON object.
items_missing422 There is no items list, or it is empty. There is nothing to reconcile.
item_not_object422 A record in the list is not an object. The record number is named in the refusal text.
item_incomplete422 A record has no kind or key. kind can be given once at the top of the request, and then it is not needed per record.
batch_too_large413 More than 1000 records in a batch. Cut them finer: for 691,314 keys that is 692 requests, not 691,314.
Our trouble, not yours
scanner_error503 The virus scanner could not check the file. Intake refuses: letting unchecked content through is not allowed. This is our trouble — try again later.
tmp_read500 The accepted file cannot be read on our side. Also our trouble.
tenant_suspended403 The tenant is suspended. The keys still work — this is not about the signature.
scanner_down503the virus scanner is unreachable — intake is closed, the file is not lost
disk_full507the service is running out of space; try again later
internal500a failure on our side; it is written to the log

A refusal always explains the reason in words and never retells a library exception: the machine string is read by our operator in the log, not by you in the answer.

Admin API

A separate entry point /api/admin/v1/* with its own config, its own log and its own signature. Only the shared control panel uses it. Tenants' signing keys are not handed to the panel: that is the right to write into someone else's storage.