Start here

Testing & sandbox

Everything in Ollacore can be exercised before you have a telecom account, an app-store build, or a push certificate. This page covers the sandbox namespace, and the one piece that needs a little scaffolding: completing a real phone signup without sending an SMS.

A sandbox tenant #

Signup is self-serve and gives you a tenant of your own. Keys are issued per environment, and test is a genuinely separate namespace from live — rooms, messages and usage written with a test key are invisible to a live key and vice versa. Point your CI at a test key and it can never touch production data.

# 1. an account, and a tenant with it
curl -X POST https://api.ollacore.com/v1/account/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"a-long-passphrase"}'
# → { "tenant_id": "acct_…", "session_token": "as_…", "expires_at": "…" }

# 2. a sandbox key — environment "test" is a separate namespace from "live"
curl -X POST https://api.ollacore.com/v1/account/api-keys \
  -H "Authorization: Bearer as_…" \
  -H "Content-Type: application/json" \
  -d '{"name":"qa-sandbox","environment":"test","scopes":["rooms:read","rooms:write","members:write","tokens:mint","messages:write"]}'
# → { "key_id": "ak_test_…", "api_key": "ak_test_….<secret>", "scopes": [ … ] }
The secret is shown once. api_key appears only in the create and rotate responses; every later read returns the key_id alone. Rotation is atomic and invalidates the previous secret immediately, so rotate in a deploy, not mid-request.

Why phone signup needs scaffolding #

Ollacore generates and verifies OTP codes but deliberately never sends them. Delivery is bring-your-own: we hand the code either to a webhook you control, or to your own SMS vendor credentials. That is what makes the directory resellable — you own the sender identity and the DLT/10DLC registration, not us.

The consequence for a test team is that otp/request succeeds and then nothing arrives, because nothing is wired up yet. There is a development-only capture endpoint, but it is hard-refused when the deployment runs with APP_ENV=production — correctly, since it would otherwise expose live users' codes.

The fix is a webhook sink, and it needs no cooperation from us. Register a test-only directory app whose delivery_webhook_url points at something you control. It receives the code, and your tests read it back. Production stays untouched and the dev endpoint stays off.
Your app  ──POST /v1/directory/otp/request──▶  Ollacore
                                                    │
                                signed webhook      │  { app_id, phone, code, expires_at }
                                                    ▼
                                         your sink  /hook
                                           │            │
                            verify HMAC ───┤            ├─── allowlist the number
                                           ▼            ▼
                                     store (10 min)   email / Slack / log
                                           │
Your test  ──GET /last-otp?phone=…&since=…─┘
                                           │
                                           ▼
           ──POST /v1/directory/otp/verify──▶  du_… session

Registering a test directory app #

curl -X POST https://api.ollacore.com/v1/account/directory-apps \
  -H "Authorization: Bearer as_…" \
  -H "Content-Type: application/json" \
  -d '{"delivery_webhook_url":"https://otp-sink.yourcompany.com/hook"}'
# → { "app_id": "da_…", "delivery_mode": "webhook",
#     "delivery_secret": "…"  ← shown once; this signs every delivery }

Keep this app separate from anything real. The delivery_secret is returned once and signs every delivery — store it as a secret in your sink, not in the repository.

The URL must resolve publicly. Deliveries go through an SSRF guard that rejects private ranges and hosts that do not resolve, so localhost, a .invalid domain and an unpublished internal name will all be refused. Use a real hostname — a small serverless function is plenty.

Verifying the delivery signature #

An OTP is a live credential, so a sink that accepts anything is a sink that will eventually be fed forged codes. Every delivery carries:

x-ollacore-signature: t=1789064267,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

where v1 is HMAC-SHA256(delivery_secret, "{t}.{raw body}") in hex. Compare in constant time, against the raw body — re-serialising the JSON first will change a byte somewhere and the signature will never match.

const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
  'raw', enc.encode(DELIVERY_SECRET),
  { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);

// "t=<unix>,v1=<hex>"
const { t, v1 } = parseSignatureHeader(req.headers.get('x-ollacore-signature'));

// Reject stale deliveries BEFORE looking anything else up, so a captured
// delivery cannot be replayed at leisure.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return json({ error: 'stale' }, 401);

const raw = await req.text();                       // the RAW body, not a re-encode
const mac = await crypto.subtle.sign('HMAC', key, enc.encode(`${t}.${raw}`));
if (!timingSafeEqual(toHex(mac), v1)) return json({ error: 'bad_signature' }, 401);

What the sink should expose #

Four routes are enough. This is the shape we run for our own test team, in about two hundred lines of a single serverless function:

RouteAuthPurpose
POST /hook Ollacore HMAC Receives the delivery. Not for humans.
GET /last-otp?phone=… Bearer SIM_TOKEN Fetch the code. Takes &since=<unix ms> and &consume=1.
GET /diag Bearer SIM_TOKEN Config self-check: counts and shapes, never values.
GET /health none Liveness.

Always poll with since #

This is the one that will cost you an afternoon. A read taken moments after requesting a code can return the previous one — left over from an earlier run, or served by a store that has not caught up yet. It looks perfectly valid, and then otp/verify answers 401, because the backend has already issued a newer code and only the newest is accepted. The failure surfaces at verification, several steps from its cause.

Fix it in the sink, not in each caller: record when the code arrived, take a since parameter, and return 404 until something newer than that instant lands. Then a polling loop is correct by construction and no test can rediscover the trap.

since guarantees correctness, not speed. If the sink keeps codes in an eventually consistent edge key-value store, the webhook is written near our backend while your test reads from near you, and the two can disagree for up to a minute — so a tester in another region waits 10–60 seconds for every code. Our own sink started on Workers KV and hit exactly this. Moving the codes to a strongly consistent store (a single Durable Object) made them readable on the first poll, in under a second. Pick the store for read-your-writes, not for global reach.

Whatever you build, keep failures in one shape and say so: our sink answers {"error": "no_fresh_code"}, and a harness that checks a code key on a 404 will never recognise "not yet".

const since = Number(url.searchParams.get('since') || 0);
if (since > 0) {
  const received = Date.parse(record.received_at || 0);
  if (!Number.isFinite(received) || received < since) {
    return json({ error: 'no_fresh_code', hint: 'keep polling' }, 404);
  }
}
if (url.searchParams.get('consume') === '1') await store.delete(`otp:${phone}`);

Keeping the sink safe #

Assume this host will one day be pointed somewhere it should not be. Four controls, in order of how much they matter:

  1. Allowlist the phone numbers Only numbers you explicitly list are ever stored or forwarded. Everything else is dropped and answered 202 — not 403, which would confirm which numbers are configured. This is the control that makes misuse structurally impossible rather than merely discouraged: even if a production app were pointed at this host, no real user's code could be harvested.
  2. Verify the signature, and the clock Constant-time compare, and reject anything outside ±300 s. Together these mean nobody can inject a code or replay a captured one.
  3. Gate the read side A bearer token on /last-otp, a per-IP rate limit, and single-use reads via consume=1 so a stale code cannot be picked up by a later run. Make the limit self-describing: a fixed window means "sleep 2 s and retry" just earns the next 429, so our 429 carries Retry-After — the seconds until the window rolls — and a harness should wait exactly that long. The limit is per IP, and CI, a laptop and every parallel emulator test behind one NAT share it.
  4. Expire quickly, log nothing A ten-minute TTL is longer than the code's own life. Never write the code to a log line — logs outlive the credential and travel further than you expect.
Never point a production directory app at a test sink. Wire it to one throwaway tenant and keep it that way.

A full signup, start to finish #

PHONE='+15550001111'
SINCE=$(($(date +%s) * 1000))

curl -sX POST https://api.ollacore.com/v1/directory/otp/request \
  -H "Content-Type: application/json" \
  -d "{\"app_id\":\"$APP_ID\",\"phone\":\"$PHONE\"}"
# → { "dispatched": true, "expires_at": "…" }   the code is NOT in this response

# poll until a code that arrived AFTER we asked shows up
CODE=$(until curl -sf -H "Authorization: Bearer $SIM_TOKEN" \
  "$SIM/last-otp?phone=%2B15550001111&since=$SINCE" | jq -re .code; do sleep 2; done)

curl -sX POST https://api.ollacore.com/v1/directory/otp/verify \
  -H "Content-Type: application/json" \
  -d "{\"app_id\":\"$APP_ID\",\"phone\":\"$PHONE\",\"code\":\"$CODE\"}"
# → { "session_token": "du_…", "user_id": "…", "display_name": null }

That du_ session is an ordinary directory credential — carry on into the directory API: /v1/directory/me, /v1/directory/inbox, conversations, devices.

From an Android instrumentation test #

@Test fun signsInWithoutAnSms() = runBlocking {
    val phone = "+15550001111"
    val since = System.currentTimeMillis()      // capture BEFORE requesting

    api.requestOtp(appId, phone)

    // Poll with `since` — never take whatever code happens to be sitting there.
    val code = withTimeout(60_000) {
        var c: String? = null
        while (c == null) {
            c = sim.lastOtp(phone, since)       // GET /last-otp, Bearer SIM_TOKEN
            if (c == null) delay(2_000)
        }
        c
    }

    val session = api.verifyOtp(appId, phone, code)
    assertTrue(session.sessionToken.startsWith("du_"))

    // The session is now a normal directory credential.
    val me = api.me(session.sessionToken)
    assertEquals(phone, me.phone)
}

Testing the rest of the platform #

  • Chat, calls, attachments, E2EE — nothing extra is needed. Mint a room token from your sandbox key and go; see the integration checklist.
  • Webhooks — point them at the same sink. Deliveries are signed the same way, and GET /v1/server/webhooks/{id}/deliveries gives you an audit log with status and attempt count, plus replay.
  • Web Push — works against a real browser subscription with no extra setup. GET /v1/push/vapid-public-key is the starting point.
  • FCM / APNs — needs real credentials, but they are write-only: set them with PUT /v1/account/directory-apps/{app_id}/push and they can never be read back, so a compromised session cannot exfiltrate them.
  • Cleaning up — offboarding a tenant erases its rooms, messages, stored objects, API keys, webhooks, push subscriptions, E2EE material and usage ledger together. Two things to know before you plan around it: tenant offboarding is an operator action on the control plane, not something your credentials can call, and it also deletes the account and its login. There is no self-serve account-deletion endpoint. Ask us to offboard a tenant.