Journey. As a developer, I want a fast, shared, tenant-isolated cache
that any of my services can use — set a value with a TTL, read it back,
let it expire, and introspect what is cached — without running my own
Redis cluster.
The Memcache service is the platform's shared key-value cache: one in-memory store per tenant, AES-encrypted at rest, with exact-match lookups and per-entry TTLs. Platform services use it to memoize expensive computations; your services can use it the same way.
| Router | Port (local) | Audience | Routes |
|---|---|---|---|
| Public | :40011 | Your services & agents | add, get, delete (+ MCP tools) |
| Private (loopback) | :40001 | Ops & platform services only | prefix listing/deletion, per-tenant flush, diagnostics |
Both routers require the tenant key pair and a user JWT (the Swagger UI and OpenAPI JSON are exempt). The private router exists on loopback precisely so administrative operations — listing every entry under a key prefix, flushing a tenant, reading hit-ratio counters — are never exposed to callers outside the host.
Prerequisites
- The tenant key pair (
x-api-key/x-client-app-key) and a user JWT from
POST /v3/auth/qa/login. Sandbox accepts any tenant id; the snippets use playground_a43b973c77e94b7f8b0e64d96c86edf8.
Concepts
- Exact match only. Lookups compare keys literally —
weather:nycand
weather:sf are unrelated entries. Semantic (similarity-based) caching is not implemented in this version of the service; plan your key names accordingly.
- Two routers, one store. Entries written on the public router are
visible on the private router (and vice versa) — they share the same tenant cache. The split is about who may call which operation, not about separate data.
- Values are encrypted at rest. Entries are AES-encrypted in the shared
store; the private router's prefix listing decrypts them for you.
- TTL is required. Every
addcarriesttl_in_seconds. A TTL of1
expires after roughly a second; a TTL of 0 is silently accepted but stores nothing (see Step 3).
- Plain-text bodies, not JSON.
getreturns the raw value with
Content-Type: text/plain; misses return an empty 404 (no JSON error body). add and delete likewise return empty bodies (201 / 200).
- Capacity is per tenant. Up to 10 million entries per tenant; on
overflow the least-recently-used entry is evicted silently — a get you expected to hit can come back 404 under memory pressure.
Step 1 — Add a value and read it back
Obtain a user JWT, then add an entry and read it back on the public router:
USER_TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g70-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g70-cacher\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "JWT obtained (${#USER_TOKEN} chars)"
ADD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"${MEMCACHE_URL}/v3/mem-cache/add/${TENANT_ID}" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d '{"key": "weather-cache-key", "value": "sunny 72F", "ttl_in_seconds": 300}')
echo "add status: ${ADD_CODE}"
test "${ADD_CODE}" = "201" && echo "entry stored"Expected — add status: 201 with an empty body. The key must be 8–120 characters and the value at most 2 MiB. ttl_in_seconds is mandatory.
Reading back returns the raw value as text/plain — not a JSON envelope:
GET_RESP=$(curl -s -w "\n%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/weather-cache-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "${GET_RESP}"
VALUE=$(echo "${GET_RESP}" | sed '$d')
CODE=$(echo "${GET_RESP}" | tail -n 1)
test "${CODE}" = "200" && test "${VALUE}" = "sunny 72F" && echo "round trip verified"Expected — the body is exactly sunny 72F followed by a captured 200 status line, then round trip verified. No JSON parsing needed — though the MCP get_cache_entry tool does wrap it in JSON (see Step 4).
Step 2 — Misses and deletes
A miss is a 404 with an empty body — distinguish it from an error by the status code alone (and from a stored empty string by calling add first). Delete returns 200 empty; afterwards the key misses:
MISS_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/never-written-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "miss status: ${MISS_CODE} (empty body)"
test "${MISS_CODE}" = "404" && echo "miss semantics verified"
DEL_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
"${MEMCACHE_URL}/v3/mem-cache/delete/${TENANT_ID}/weather-cache-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "delete status: ${DEL_CODE}"
test "${DEL_CODE}" = "200" || exit 1
GET_AFTER=$(curl -s -o /dev/null -w "%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/weather-cache-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "get-after-delete status: ${GET_AFTER}"
test "${GET_AFTER}" = "404" && echo "delete verified"Expected — miss status: 404, delete status: 200, then get-after-delete status: 404.
Step 3 — TTL expiry (and the ttl=0 gotcha)
A short TTL expires quickly; give a 1-second entry two seconds and it is gone. This is the mechanism behind "memoize for N seconds" patterns:
curl -s -o /dev/null -w "add: %{http_code}\n" -X POST \
"${MEMCACHE_URL}/v3/mem-cache/add/${TENANT_ID}" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d '{"key": "short-lived-key", "value": "ephemeral", "ttl_in_seconds": 1}'
sleep 2
CODE=$(curl -s -o /dev/null -w "%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/short-lived-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "after 2s: ${CODE}"
test "${CODE}" = "404" && echo "TTL expiry verified"Expected — add: 201, then after 2s: 404 and TTL expiry verified.
One wart to know: ttl_in_seconds: 0 is accepted with 201 but stores nothing — a subsequent get misses. If you treat "zero" as "no expiry" (as some cache APIs do), you will silently lose data. Always pass a real TTL:
ZERO_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"${MEMCACHE_URL}/v3/mem-cache/add/${TENANT_ID}" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d '{"key": "zero-ttl-key", "value": "lost forever", "ttl_in_seconds": 0}')
echo "ttl=0 add status: ${ZERO_CODE} (looks successful...)"
GET_ZERO=$(curl -s -o /dev/null -w "%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/zero-ttl-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "ttl=0 get status: ${GET_ZERO} (...but nothing was stored)"
test "${ZERO_CODE}" = "201" && test "${GET_ZERO}" = "404" && echo "ttl=0 no-op documented"Expected — ttl=0 add status: 201, then ttl=0 get status: 404: a false-success gotcha. Use a generous TTL (or re-add on miss) instead.
Step 4 — The private router: list by prefix, flush, and diagnostics
The loopback router (:40001 locally) exposes administrative operations that are deliberately absent from the public surface. All of them need the same key pair and JWT — the router enforces authentication too.
Re-create two entries, then list everything under a key prefix (values come back decrypted):
for kv in "g70-alpha:one" "g70-beta:two"; do
KEY=${kv%%:*}; VAL=${kv#*:}
curl -s -o /dev/null -w "add ${KEY}: %{http_code}\n" -X POST \
"${MEMCACHE_URL}/v3/mem-cache/add/${TENANT_ID}" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d "{\"key\": \"${KEY}\", \"value\": \"${VAL}\", \"ttl_in_seconds\": 600}"
done
LIST=$(curl -s \
"${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/${TENANT_ID}/entries/prefix/g70" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "${LIST}" | python3 -c '
import json, sys
entries = json.load(sys.stdin)
keys = sorted(e["key"] for e in entries)
assert keys == ["g70-alpha", "g70-beta"], keys
values = {e["key"]: e["value"] for e in entries}
assert values["g70-alpha"] == "one" and values["g70-beta"] == "two", values
print("prefix list verified:", keys)
'Expected — a 200 JSON array [{"key":"g70-alpha","shard":null,"value":"one"},…] (the shard field is currently always null) and prefix list verified. The prefix match is literal string-prefix on keys.
Diagnostics report entry counts and hit/miss counters — useful for cache effectiveness checks. The counters are process-global (across all tenants) and reset to zero when the service restarts, so assert deltas, not absolute values. Here we take a baseline, perform exactly one hit (the g70-alpha entry from above) and one miss, and verify the counters moved accordingly:
COUNT=$(curl -s "${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/diagnostics/entry-count" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "entry count: ${COUNT}"
echo "${COUNT}" | python3 -c '
import json, sys
n = json.load(sys.stdin)["number_of_entries"]
assert n >= 2, n # at least the two g70-* entries from above
print("entry count verified:", n)
'
BASE=$(curl -s "${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/diagnostics/performance-stats" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "baseline: ${BASE}"
curl -s -o /dev/null "${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/g70-alpha" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}"
curl -s -o /dev/null "${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/g70-never-written" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}"
AFTER=$(curl -s "${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/diagnostics/performance-stats" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "after 1 hit + 1 miss: ${AFTER}"
BASE_HITS=$(echo "${BASE}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["cache_hits"])')
BASE_MISSES=$(echo "${BASE}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["cache_misses"])')
BASE_ACCESS=$(echo "${BASE}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["cache_accesses"])')
echo "${AFTER}" | BASE_HITS="${BASE_HITS}" BASE_MISSES="${BASE_MISSES}" BASE_ACCESS="${BASE_ACCESS}" python3 -c '
import json, os, sys
stats = json.load(sys.stdin)
assert stats["cache_hits"] == int(os.environ["BASE_HITS"]) + 1, stats
assert stats["cache_misses"] == int(os.environ["BASE_MISSES"]) + 1, stats
assert stats["cache_accesses"] == int(os.environ["BASE_ACCESS"]) + 2, stats
assert abs(stats["cache_hit_ratio"] - 100.0 * stats["cache_hits"] / stats["cache_accesses"]) < 0.01, stats
print("performance stats verified: +1 hit, +1 miss, ratio is a 0-100 percentage")
'Expected — entry count verified: N with N ≥ 2 (the two g70-* entries, plus anything still live from Steps 1–3), then after 1 hit + 1 miss shows each counter up by exactly one (two for cache_accesses) versus the baseline, and cache_hit_ratio recomputed as a 0–100 percentage (e.g. 50.0 after an even mix). Only public-router get calls count toward these counters; private-router listing and diagnostics reads do not.
Finally, wipe the prefix and confirm it is gone. (Related routes: DELETE /v3/mem-cache-internal/admin/{tenant_id}/entries flushes one tenant, DELETE /v3/mem-cache-internal/admin/entries flushes all — the multi-tenant flush is for operators, not apps.)
WIPE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
"${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/${TENANT_ID}/entries/prefix/g70" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "prefix wipe status: ${WIPE}"
test "${WIPE}" = "200" || exit 1
REMAIN=$(curl -s \
"${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/${TENANT_ID}/entries/prefix/g70" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "remaining under prefix: ${REMAIN}"
test "${REMAIN}" = "[]" && echo "prefix wipe verified"Expected — prefix wipe status: 200, then remaining under prefix: [].
Step 5 — Error contract and quirks
A few behaviors worth designing around — verified against the live service:
# (a) Unauthenticated: 401 JSON, on both routers
UNAUTH=$(curl -s -w "\n%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/weather-cache-key" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}")
echo "${UNAUTH}"
echo "${UNAUTH}" | sed '$d' | grep -q '"Missing Authorization token"' \
&& test "$(echo "${UNAUTH}" | tail -n 1)" = "401" \
&& echo "unauthenticated 401 verified"
# (b) Short key: 400 plain text on add — but 500 on get (asymmetric wart)
SHORT_ADD=$(curl -s -w "\n%{http_code}" -X POST \
"${MEMCACHE_URL}/v3/mem-cache/add/${TENANT_ID}" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d '{"key": "tiny", "value": "v", "ttl_in_seconds": 60}')
echo "${SHORT_ADD}"
SHORT_GET=$(curl -s -o /dev/null -w "%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/tiny" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
echo "short-key get status: ${SHORT_GET} (500, not 400 — known asymmetry)"
test "$(echo "${SHORT_ADD}" | tail -n 1)" = "400" && test "${SHORT_GET}" = "500" \
&& echo "short-key asymmetry documented"Expected — (a) a 401 body {"error":"Missing Authorization token"}; (b) add rejects the 4-character key with 400 Key is too small (plain text), while the same short key on get returns 500 — a known asymmetry: validate key length client-side and treat any 5xx from get as "bad request shape", not "server down".
Two more quirks, stated without live demos:
tagsandshardare accepted but discarded. Theaddpayload
accepts these fields and the private listing always reports "shard": null; do not rely on them for grouping or routing.
- Oversized values are rejected. The value limit is 2 MiB per entry;
per-tenant capacity is 10 M entries with silent LRU eviction.
And the one thing this version does not do: semantic caching. There is no fuzzy/similarity lookup — get is exact-match only. (A semantic-match library ships in the workspace but is not wired into this service version; brochure claims to that effect are aspirational.)
Putting it together
A compact cache-aside pattern: check the cache, compute on miss, store with a TTL, and use the private router for introspection:
# cache-aside read for "expensive-computation"
KEY="expensive-computation"
HIT=$(curl -s -o /tmp/g70-hit.txt -w "%{http_code}" \
"${MEMCACHE_URL}/v3/mem-cache/get/${TENANT_ID}/${KEY}" \
-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}")
if [ "${HIT}" = "200" ]; then
RESULT=$(cat /tmp/g70-hit.txt); echo "cache hit: ${RESULT}"
else
RESULT=$(python3 -c 'import hashlib; print(hashlib.sha256(b"work").hexdigest()[:16])')
echo "computed: ${RESULT}; storing for 5 min"
curl -s -o /dev/null -X POST "${MEMCACHE_URL}/v3/mem-cache/add/${TENANT_ID}" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d "{\"key\": \"${KEY}\", \"value\": \"${RESULT}\", \"ttl_in_seconds\": 300}"
fi
# ops introspection on the loopback router
curl -s "${MEMCACHE_INTERNAL_URL}/v3/mem-cache-internal/diagnostics/performance-stats" \
-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}"; echo(MEMCACHE_URL / MEMCACHE_INTERNAL_URL are the public/loopback base URLs — http://127.0.0.1:40011 / http://127.0.0.1:40001 locally; AUTH_URL is http://127.0.0.1:40004; TENANT_ID is your tenant.)
Try it live
Open the Playground → Infrastructure & Storage — the Memcache console exercises add / get / delete against the live service.
Agents can also call the cache directly: the service exposes MCP tools add_cache_entry, get_cache_entry, and delete_cache_entry on the public router (/agent/v1/mcp, streamable HTTP, spec 2026-07-28), under the same key pair + JWT authentication.
Copy agent prompt
You are working with the Inaya Labs Memcache service. The public router is
MEMCACHE_URL (:40011), the private loopback router is MEMCACHE_INTERNAL_URL
(:40001), Auth is AUTH_URL (:40004); the tenant key pair is in INAYA_API_KEY /
INAYA_APP_KEY and TENANT_ID. EVERY route on BOTH routers requires the key pair
headers AND a user JWT: obtain it via POST AUTH /v3/auth/qa/login
{"device_id","locale":"en-US","tenant_id","username"} -> .token. Steps:
(1) Add: POST MEMCACHE /v3/mem-cache/add/{tenant} {"key","value",
"ttl_in_seconds"} -> 201 EMPTY body. Key must be 8-120 chars; value <= 2 MiB;
ttl_in_seconds is REQUIRED and ttl=0 stores NOTHING (201 but immediate miss).
(2) Get: GET /v3/mem-cache/get/{tenant}/{key} -> 200 with the RAW value as
text/plain (not JSON); miss/expired -> 404 with EMPTY body. (3) Misses &
delete: DELETE /v3/mem-cache/delete/{tenant}/{key} -> 200 empty; a subsequent
get is 404. (4) TTL: ttl_in_seconds 1 expires after ~1-2 s (verify with sleep 2
then expect 404). (5) Private router (same auth!): GET
MEMCACHE_INTERNAL /v3/mem-cache-internal/{tenant}/entries/prefix/{prefix} ->
200 JSON array of {"key","shard":null,"value"} (decrypted); DELETE same path
wipes the prefix -> then list returns []; diagnostics GET
/v3/mem-cache-internal/diagnostics/entry-count -> {"number_of_entries":N} and
/v3/mem-cache-internal/diagnostics/performance-stats ->
{"cache_accesses","cache_hit_ratio","cache_hits","cache_misses"}. (6) Error
contract: no JWT -> 401 {"error":"Missing Authorization token"}; a short (<8
char) key -> 400 plain text on add but 500 on get (known asymmetry — validate
keys client-side); "tags"/"shard" fields are accepted on add but discarded;
lookups are EXACT MATCH ONLY — semantic caching is NOT implemented in this
version. Report status codes and bodies per step; never log the JWT.API reference
Next journeys
- Storing and serving files with CDN (G-71) — when your values are files, not strings.
- Uploading files (G-02) — the upload flow that places files into the CDN.
- Errors, retries & idempotency (G-04) — how to treat
404misses and5xxresponses in retry logic.