Integration examples: one call, four platforms
Squiddy's data plane is one procedure, /squiddy.v1.ConfigService/GetRemoteConfiguration, spoken over Connect — which means an ordinary JSON POST. There is nothing to install: the examples below are the same call in four languages, sending the same body and reading the same answer with nothing but what each platform ships with.
There is no SDK yet, and that is why these exist. When one arrives it will not make them wrong — it will cover some of these platforms, and the rest will still call the service exactly like this.
If you do not have an organization, an API key and a published configuration yet, start from the quickstart: it takes you from signing up to your first answer. This page is that same call, in your language.
The call
| What | Value |
|---|---|
| Method | POST |
| Path | /squiddy.v1.ConfigService/GetRemoteConfiguration |
| Credential | X-API-Key: <your key> |
| Body | JSON (application/json), the six fields below |
The key is what addresses the configuration. It belongs to exactly one environment, and an environment to exactly one project, so presenting it tells the server which organization, project and environment you mean: none of the three is in the body. Note the header — X-API-Key, not an Authorization: Bearer, which this plane does not read.
The values you supply
Every example takes these three from the environment. The key is a credential, so it is read from the environment rather than passed as an argument: an argument is in your shell history and in every process listing on the machine.
| Variable | What to put in it |
|---|---|
SQUIDDY_HOST | The origin your Squiddy is served on, with no trailing slash: the scheme and host in your browser's address bar while you are signed in to the dashboard (http://localhost:7071 for a local run). |
SQUIDDY_API_KEY | An API key secret. It is shown once, when the key is created. |
SQUIDDY_APP_ID | The id of the app you published. Nothing published yet? Any id — say my-app — still runs, and is answered not_found, which already proves your key, your endpoint and your coordinates. |
The request body
All six fields are required. appId names the configuration you want; the other five describe the app asking for it, and are what resolution is decided on — which is why they are in every example rather than hidden behind a default.
| Field | What it is |
|---|---|
appId | The app whose configuration you are asking for. |
appBundleId | Your app's bundle identifier. Put your own in. |
appVersion | The running version, in YYYY.MM.DD. It is compared numerically against the rules a configuration carries, so 1.0.0 would sort below every rule written for a real release. |
appType | The platform: a rule can answer one thing to ios and another to android. |
appMode | The build: likewise, release and debug can be answered differently. |
appLanguage | The language the dictionary in the answer is for. |
The fields the examples read
The answer carries more than this — every field of it is in the ConfigService reference. Each example reads these 3, one of them nested, because reading a nested field is the part every language spells differently.
| Field | What it tells you |
|---|---|
configVersion | The version of the configuration that answered. It changes when you publish. |
serverApi.baseUrl | A field of the payload itself: the backend coordinates your app reads out of it. |
configFingerprint | Send it back as the request's configFingerprint next time and an unchanged configuration answers "notModified": true with the heavy blocks left out. |
What each one needs
Every command below expects the three variables above in the environment.
| Example | Needs | Run |
|---|---|---|
| curl | curl and jq, plus a POSIX shell | sh docs/examples/first-call.sh |
| TypeScript | Node 20+, Deno or Bun — the built-in fetch, nothing installed | npx tsx docs/examples/first-call.ts |
| Kotlin | JDK 11+ and a JSON reader — org.json, which Android ships and the JVM gets from org.json:json. On Android put OkHttp where java.net.http is: the request is unchanged | kotlinc FirstCall.kt -cp json.jar -d app.jar && java -cp app.jar:json.jar FirstCallKt |
| Swift | Swift 5.7+ and Foundation — nothing to install, on macOS or on Linux | swift docs/examples/FirstCall.swift |
curl
The file: docs/examples/first-call.sh.
#!/usr/bin/env sh
#
# Squiddy — the first data-plane call, in curl. No SDK, and no dependency on Squiddy: plain
# HTTP/JSON on the public contract.
#
# It POSTs the six-field body below to /squiddy.v1.ConfigService/GetRemoteConfiguration,
# presenting the API key in the X-API-Key header. A key is bound to one environment, so the server
# derives the organization, the project and the environment from it — there is nothing to
# address by hand.
#
# Needs: `curl` and `jq`, plus a POSIX shell.
# Run: sh docs/examples/first-call.sh
# with SQUIDDY_HOST, SQUIDDY_API_KEY and SQUIDDY_APP_ID in the environment.
set -eu
# `:?` refuses to run with an input missing, instead of sending an empty one and leaving you to
# read the refusal as a problem with the product.
: "${SQUIDDY_HOST:?is not set: see docs/examples.md for what goes in it}"
: "${SQUIDDY_API_KEY:?is not set: see docs/examples.md for what goes in it}"
: "${SQUIDDY_APP_ID:?is not set: see docs/examples.md for what goes in it}"
# The key travels in a header, never in the URL and never in the body.
response=$(curl -sS -X POST "$SQUIDDY_HOST/squiddy.v1.ConfigService/GetRemoteConfiguration" \
-H 'Content-Type: application/json' \
-H "X-API-Key: $SQUIDDY_API_KEY" \
--data-binary @- <<JSON
{
"appId": "$SQUIDDY_APP_ID",
"appBundleId": "com.example.app",
"appVersion": "2026.01.01",
"appType": "ios",
"appMode": "release",
"appLanguage": "en"
}
JSON
)
# A refusal answers with a Connect error object — `code` and `message` — where a configuration
# would be. Reading the fields off it anyway would report an empty configuration as a real one.
if printf '%s' "$response" | jq -e 'has("code")' >/dev/null; then
printf 'the call was refused: %s\n' "$response" >&2
exit 1
fi
# The fields this example reads. The whole answer is on stdout above; `jq` is only reading it.
printf '%s' "$response" | jq -r '
"configVersion " + .configVersion,
"serverApi.baseUrl " + .serverApi.baseUrl,
"configFingerprint " + .configFingerprint'
TypeScript
The file: docs/examples/first-call.ts.
//
// Squiddy — the first data-plane call, in TypeScript. No SDK, and no dependency on Squiddy:
// plain HTTP/JSON on the public contract.
//
// It POSTs the six-field body below to /squiddy.v1.ConfigService/GetRemoteConfiguration,
// presenting the API key in the X-API-Key header. A key is bound to one environment, so the
// server derives the organization, the project and the environment from it — there is nothing
// to address by hand.
//
// Needs: Node 20+, Deno or Bun — the built-in `fetch`, nothing installed.
// Run: npx tsx docs/examples/first-call.ts
// with SQUIDDY_HOST, SQUIDDY_API_KEY and SQUIDDY_APP_ID in the environment.
/** requireEnv reads one required input, refusing to run rather than sending an empty value. */
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`${name} is not set: see docs/examples.md for what goes in it`);
}
return value;
}
const host = requireEnv("SQUIDDY_HOST");
const apiKey = requireEnv("SQUIDDY_API_KEY");
const appId = requireEnv("SQUIDDY_APP_ID");
// The body is written as JSON text, not built from an object, so what is on the wire is what you
// see here.
const body = `
{
"appId": "${appId}",
"appBundleId": "com.example.app",
"appVersion": "2026.01.01",
"appType": "ios",
"appMode": "release",
"appLanguage": "en"
}
`;
const response = await fetch(`${host}/squiddy.v1.ConfigService/GetRemoteConfiguration`, {
method: "POST",
// The key travels in a header, never in the URL and never in the body.
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
body,
});
if (!response.ok) {
throw new Error(`the call was refused with HTTP ${response.status}: ${await response.text()}`);
}
/** The fields this example reads. The reference documents every other one. */
interface RemoteConfiguration {
readonly configVersion: string;
readonly serverApi: { readonly baseUrl: string };
readonly configFingerprint: string;
}
const payload = (await response.json()) as RemoteConfiguration;
console.log("configVersion " + payload.configVersion);
console.log("serverApi.baseUrl " + payload.serverApi.baseUrl);
console.log("configFingerprint " + payload.configFingerprint);
Kotlin
The file: docs/examples/FirstCall.kt.
//
// Squiddy — the first data-plane call, in Kotlin. No SDK, and no dependency on Squiddy: plain
// HTTP/JSON on the public contract.
//
// It POSTs the six-field body below to /squiddy.v1.ConfigService/GetRemoteConfiguration,
// presenting the API key in the X-API-Key header. A key is bound to one environment, so the
// server derives the organization, the project and the environment from it — there is nothing
// to address by hand.
//
// Needs: JDK 11+ and a JSON reader — `org.json`, which Android ships and the JVM gets from
// `org.json:json`. On Android put OkHttp where `java.net.http` is: the request is
// unchanged.
// Run: kotlinc FirstCall.kt -cp json.jar -d app.jar && java -cp app.jar:json.jar FirstCallKt
// with SQUIDDY_HOST, SQUIDDY_API_KEY and SQUIDDY_APP_ID in the environment.
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import org.json.JSONObject
/** requireEnv reads one required input, refusing to run rather than sending an empty value. */
fun requireEnv(name: String): String = System.getenv(name)?.takeIf { it.isNotEmpty() }
?: error("$name is not set: see docs/examples.md for what goes in it")
fun main() {
val host = requireEnv("SQUIDDY_HOST")
val apiKey = requireEnv("SQUIDDY_API_KEY")
val appId = requireEnv("SQUIDDY_APP_ID")
// The body is written as JSON text, not built from a JSONObject, so what is on the wire is
// what you see here.
val body = """
{
"appId": "$appId",
"appBundleId": "com.example.app",
"appVersion": "2026.01.01",
"appType": "ios",
"appMode": "release",
"appLanguage": "en"
}
""".trimIndent()
val url = URI.create(host + "/squiddy.v1.ConfigService/GetRemoteConfiguration")
val request = HttpRequest.newBuilder(url)
.header("Content-Type", "application/json")
// The key travels in a header, never in the URL and never in the body.
.header("X-API-Key", apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()
val response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() != 200) {
error("the call was refused with HTTP ${response.statusCode()}: ${response.body()}")
}
val payload = JSONObject(response.body())
println("configVersion " + payload.getString("configVersion"))
println("serverApi.baseUrl " + payload.getJSONObject("serverApi").getString("baseUrl"))
println("configFingerprint " + payload.getString("configFingerprint"))
}
Swift
The file: docs/examples/FirstCall.swift.
//
// Squiddy — the first data-plane call, in Swift. No SDK, and no dependency on Squiddy: plain
// HTTP/JSON on the public contract.
//
// It POSTs the six-field body below to /squiddy.v1.ConfigService/GetRemoteConfiguration,
// presenting the API key in the X-API-Key header. A key is bound to one environment, so the
// server derives the organization, the project and the environment from it — there is nothing
// to address by hand.
//
// Needs: Swift 5.7+ and Foundation — nothing to install, on macOS or on Linux.
// Run: swift docs/examples/FirstCall.swift
// with SQUIDDY_HOST, SQUIDDY_API_KEY and SQUIDDY_APP_ID in the environment.
import Foundation
/// requireEnv reads one required input, refusing to run rather than sending an empty value.
func requireEnv(_ name: String) -> String {
guard let value = ProcessInfo.processInfo.environment[name], !value.isEmpty else {
fatalError("\(name) is not set: see docs/examples.md for what goes in it")
}
return value
}
let host = requireEnv("SQUIDDY_HOST")
let apiKey = requireEnv("SQUIDDY_API_KEY")
let appId = requireEnv("SQUIDDY_APP_ID")
// The body is written as JSON text, not built from a dictionary, so what is on the wire is what
// you see here.
let body = """
{
"appId": "\(appId)",
"appBundleId": "com.example.app",
"appVersion": "2026.01.01",
"appType": "ios",
"appMode": "release",
"appLanguage": "en"
}
"""
guard let url = URL(string: host + "/squiddy.v1.ConfigService/GetRemoteConfiguration") else {
fatalError("SQUIDDY_HOST is not a URL: \(host)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// The key travels in a header, never in the URL and never in the body.
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
request.httpBody = Data(body.utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard let status = (response as? HTTPURLResponse)?.statusCode, status == 200 else {
fatalError("the call was refused: " + (String(data: data, encoding: .utf8) ?? ""))
}
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
print("configVersion " + (payload["configVersion"] as? String ?? ""))
print("serverApi.baseUrl " + ((payload["serverApi"] as? [String: Any])?["baseUrl"] as? String ?? ""))
print("configFingerprint " + (payload["configFingerprint"] as? String ?? ""))
What they print
All four, on a served configuration:
configVersion 2026.08.07
serverApi.baseUrl https://api.example.com/v1/
configFingerprint a36f1250…3574c1
A brand-new organization has published nothing, so the same call is answered not_found (404, application code 5003) until you publish something — see step 5 of the quickstart, which shows all three answers the call can get.
Where to go next
- The ConfigService reference: every field of the request and the answer, every error the call can be refused with, generated from the contract itself.
- The quickstart, if you got here first: creating the organization, the key and the configuration this call needs.
- The AdminService reference: everything the dashboard does, which you can therefore automate.
Caching, in one line: keep configFingerprint and send it back in the next request. An unchanged configuration is then answered "notModified": true with the payload left out, which is a few hundred bytes instead of a few kilobytes and is the difference between an app that can ask often and one that cannot.