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
| Endpoint | What 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/ping | A 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
- Cut your list into batches of 1000 and send each to
POST /api/v1/reconcilewith your sizes and hashes. - Look at
summary: ifokequals the number of records, the batch matched and there is no point reading the rest. - If it did not match,
itemsholds 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
| status | what happened |
|---|---|
ok | It is there, and the size and hash match. If you
sent neither a size nor a hash, ok only means “it is
there”. |
missing | The service has no such key (or no such variant of it). This is what needs sending again. |
mismatch | It is there, but not the same: why
says whether the size or the hash differs. |
pending | The object was accepted, the variant is still being made — video in the queue. There is nothing to resend, only to wait. |
unverified | It 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." } }
| Code | HTTP | When |
|---|---|---|
| Signature | ||
sign_missing | 401 | no signing headers |
sign_bad_ts | 401 | X-Media-Ts is not a number of unix seconds |
sign_stale | 401 | the signature is older than 300 seconds — check the clock |
key_unknown | 401 | the key was not found or is retired |
sign_bad | 403 | the signature does not match; most often the query string was not signed along with the path |
sign_replay | 409 | this signature has been used — recompute with a fresh X-Media-Ts |
| What was sent | ||
body_empty | 422 | the body is empty: the service expects the file itself |
body_truncated | 400 | the body did not arrive whole — a transfer break, send it again |
too_large | 413 | the file is larger than your ceiling |
svg_forbidden | 415 | an SVG was sent — never accepted and never served |
type_rejected | 415 | the type is not accepted; the answer says what can be seen in the bytes |
file_truncated | 422 | the file is cut short: the length does not match the header. That is a break, not a bad picture |
file_broken | 422 | the header looks like a picture, but it cannot be parsed as one |
unreadable | 422 | the file does not parse as a picture |
infected | 422 | the file was rejected by the virus scanner |
| What was asked for | ||
no_route | 404 | No such endpoint. The method and path are named in the refusal text — a typo is visible at once. |
key_too_long | 422 | The key is longer than 190 bytes. The key is your identifier, not a file name. |
variant_empty | 422 | No variant was named, and the kind has no default variant. |
too_many_keys | 422 |
More than 1000 keys in resolve-batch. |
args_missing | 422 | kind, key or variant is missing |
kind_unknown | 422 | that kind is not allowed for you; the answer lists the allowed ones |
variant_unknown | 422 | the service has no such variant — the set is named and finite |
variant_not_allowed | 422 | the variant exists but is not set up for this kind |
not_found | 404 | the service has no such object |
asset_in_stock | 409 | an object under this owner_ref is linked to stock; erase deletes nothing, even when the item is withdrawn |
asset_busy | 503 | another operation is changing this asset; no bytes were deleted. Retry after a short delay with a fresh signature |
| Ceilings and money | ||
plan_required | 402 | this is not included in your plan |
wallet_empty | 402 | the wallet is in the red: intake is closed, delivery works |
rate_limited | 429 | too many uploads per minute |
quota_exceeded | 507 | your storage ceiling is used up |
| Reconciling | ||
body_not_json | 422 | The reconciliation request body is not a JSON object. |
items_missing | 422 |
There is no items list, or it is empty. There is nothing to
reconcile. |
item_not_object | 422 | A record in the list is not an object. The record number is named in the refusal text. |
item_incomplete | 422 |
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_large | 413 | 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_error | 503 | 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_read | 500 | The accepted file cannot be read on our side. Also our trouble. |
tenant_suspended | 403 | The tenant is suspended. The keys still work — this is not about the signature. |
scanner_down | 503 | the virus scanner is unreachable — intake is closed, the file is not lost |
disk_full | 507 | the service is running out of space; try again later |
internal | 500 | a 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.