Build it

Turnkey identity

The optional layer that removes the "how do users log in?" problem. Your client talks to Ollacore directly: phone number, one-time code, contacts, conversations, tokens. No app-server of your own.

Opt-in, per tenant. If you never provision a directory app, none of this exists and the backend stores no end-user PII. Turning it on is what makes phone numbers, display names and — if your users set them — about lines and profile photos appear.

1 · Provision an app #

A directory app is a public app_id (safe to embed in your mobile build, like a Firebase project id) plus how OTPs get delivered. It resolves the tenant for every unauthenticated directory call.

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://your-server.example.com/otp"}'
# → { "app_id": "da_9f2c…",            ← public, embed in your app
#     "delivery_mode": "webhook",
#     "delivery_secret": "…" }          ← shown once; verifies our signature

Or let us call your SMS vendor with credentials you supply:

-d '{"provider":{"kind":"twilio","config":{
      "account_sid":"AC…","auth_token":"…","from":"+15550001111",
      "message_template":"Your code is {code}"}}}'
# kinds: twilio | plivo | msg91
Test the delivery URL straight after creating the app. At creation we refuse a delivery_webhook_url that is not https:// or whose address is not public (private, loopback, link-local or otherwise reserved), with 400. A name that does not resolve yet is accepted, and every OTP delivery checks the URL again — so one that still does not resolve fails when your first user asks for a code. Request a code for a test number right away.

Rotating credentials. SMS vendor credentials cannot be updated in place, and the app_id is baked into builds already on phones, so rotate with an overlap: issue new credentials at the vendor (keeping the old ones live), create a new app with them, ship a build that uses the new app_id, and keep the old app until the builds that embed it are retired or force-upgraded. Only then delete the old app and revoke the old credentials. Users belong to your tenant, not to an app, so they carry over. Push credentials can be replaced with PUT …/push, but it is a full replace: a side you leave out (fcm or apns) is cleared.

2 · Deliver the code #

In webhook mode we generate and verify the code but you put it on the wire. The plaintext code never persists on our side — only a hash — so this POST is your one chance to send it.

POST https://your-server.example.com/otp
x-ollacore-signature: t=1757500000,v1=<hex hmac-sha256>

{ "app_id": "da_9f2c…", "phone": "+14155550123",
  "code": "418322", "expires_at": "2026-09-10T11:09:00Z" }

# Verify: HMAC-SHA256(delivery_secret, "<t>.<raw body>") == v1, then send by SMS.
We hold no telecom account. DLT registration, sender ids and carrier relationships stay entirely yours. That is deliberate: it keeps you in control of deliverability and compliance in your market.

3 · Sign the user in #

POST /v1/directory/otp/request   { "app_id": "da_…", "phone": "+14155550123" }
# → { "dispatched": true, "expires_at": "…" }   (uniform — never reveals if registered)

POST /v1/directory/otp/verify    { "app_id": "da_…", "phone": "+14155550123", "code": "418322" }
# → { "session_token": "du_…", "user_id": "…", "display_name": null }
  • Codes are argon2-hashed, expire in 5 minutes, and allow at most 5 attempts.
  • Rate limited three ways — per phone, per IP and per app. The per-app cap is what blunts SMS-pumping fraud, which would otherwise run up your vendor bill.
  • Responses are uniform: requesting a code never reveals whether the number is registered.
  • The returned du_… session is opaque, hashed at rest, and long-lived.

4 · Profile and contacts #

GET   /v1/directory/me                      # { id, phone, display_name, about, avatar_version }
PATCH /v1/directory/me                      # { "display_name": "Alice", "about": "…" }  either or both

POST  /v1/directory/contacts/lookup         # { "phones": ["+1…", …] }  ≤ 500
# → { "contacts": [ { user_id, phone, display_name, about, avatar_version } ] }   registered subset, this tenant only

Contact lookup is scoped to the caller's tenant, so one customer's user base is never discoverable from another's app. The about line is up to 256 characters; an empty string clears it.

Profile photo

POST   /v1/directory/me/avatar/init        { "declared_mime": "image/jpeg", "byte_size": 48213 }
# → { upload_id, upload_url, expires_at, max_bytes }      PUT the raw bytes to upload_url
POST   /v1/directory/me/avatar/complete    { "upload_id": "…" }
# → the profile, with its new avatar_version; 415 if the bytes are not an image
DELETE /v1/directory/me/avatar

GET    /v1/directory/users/{user_id}/avatar?size=thumb|full
# → 302 to a short-lived URL for the bytes; ETag = avatar_version. Send If-None-Match → 304.

The upload is the same presigned-PUT pattern as an attachment: JPEG, PNG, WebP or GIF, at most 2 MiB, so downscale on the device first. complete is synchronous — the bytes are sniffed, decoded and thumbnailed before it answers, so its answer is final and the previous photo is gone. Every list that names a user (contacts, members, the inbox peer) carries avatar_version and never a URL: fetch the picture once per version and cache the bytes by it. The photo is not visible across a block.

5 · Conversations #

POST /v1/directory/conversations/direct   { "peer_user_id": "…" }
# → { room_id, kind: "direct" }   idempotent: same pair always returns the same room

POST /v1/directory/conversations/group    { "member_user_ids": ["…"], "name": "Team" }
# → { room_id, kind: "group", name, not_added: [ { user_id, reason: "blocked" } ] }
GET  /v1/directory/conversations          # room_id, kind, name, description, icon_attachment_id, created_at
GET  /v1/directory/conversations/{room_id}/token   # → the room-token bundle

Directory conversations are ordinary rooms — everything in Chat and Calls applies unchanged once you hold the token. Peers must be directory users of the same tenant; anything else is refused. A group holds at most 5,000 members, and anyone you name who has blocked you is left out and listed in not_added.

Managing a group

GET    /v1/directory/conversations/{room_id}            # any member: name, description, icon_attachment_id, member_count
PATCH  /v1/directory/conversations/{room_id}            # owner: { "name"?, "description"?, "icon_attachment_id"? }  null clears
GET    /v1/directory/conversations/{room_id}/members    # any member; owners first → { members: [ { user_id, display_name, about, avatar_version, role, joined_at } ] }
POST   /v1/directory/conversations/{room_id}/members    # owner: { "member_user_ids": [ … ] }  ≤ 256; 409 once the room holds 5,000
PUT    /v1/directory/conversations/{room_id}/members/{user_id}   # owner: { "role": "owner" | "member" }
DELETE /v1/directory/conversations/{room_id}/members/{user_id}   # owner
POST   /v1/directory/conversations/{room_id}/leave      # anyone → { left, new_owner, closed }

Owners are the admins — a group may have any number of them, and the last one cannot step down without making someone else an owner first. Changes to the name, description or icon are posted into the room as system messages (group.renamed, group.description_changed, group.icon_changed), so every member sees them live and they stay in the history; membership changes arrive as member.added, member.removed and member.updated. The icon is an image attachment you first upload to the group's own room, then name in the PATCH; render it by id and refetch only when the id changes. When the only owner leaves, the longest-standing member becomes owner; when nobody remains, the room closes.

Invite links

POST   /v1/directory/conversations/{room_id}/invites    # owner: { "expires_in_seconds"?: ≤ 2592000, "max_uses"?: n }
# → { invite_id, token, expires_at, max_uses, uses, … }  the token is shown here and nowhere else
GET    /v1/directory/conversations/{room_id}/invites    # owner: live links, without tokens
DELETE /v1/directory/conversations/{room_id}/invites/{invite_id}

POST   /v1/directory/invites/preview   { "token": "…" }   # → { room_id, name, description, icon_attachment_id, member_count, member, expires_at }
POST   /v1/directory/invites/join      { "token": "…" }   # → { room_id, joined, member_count }

Whoever in your tenant presents the token joins as a member, under the room cap, until the link is revoked, expired or used up. Only a hash of the token is kept, so it appears once, in the answer that created it; a group holds at most 20 live links and an expiry is at most 30 days out. The token travels in the request body, never in a URL, and every way a link can be no good — unknown, another tenant's, revoked, expired, used up, group closed — is one 404, so a token tells a stranger nothing.

6 · Mute and block #

Mute

PUT    /v1/directory/conversations/{room_id}/mute   { "duration": "8h" | "1w" | "always" }
# → { room_id, muted_until }      "9999-12-31T00:00:00Z" stands for "until unmuted"
DELETE /v1/directory/conversations/{room_id}/mute   # → { room_id, muted_until: null }

A mute silences push notifications only, for eight hours, a week or until unmuted: the WebSocket still delivers every event, a call still rings, and a message that mentions the muted user is pushed regardless. It is private — no event is emitted and nobody else can see it. The inbox shows your own muted_until on each conversation, so drawing the icon needs no further request. On the customer plane your server does the same for a member with PUT /v1/server/rooms/{room_id}/members/{principal_id}/mute.

Block

GET    /v1/directory/blocks                 # → { blocks: [ { user_id, display_name, phone, since } ] }
PUT    /v1/directory/blocks/{user_id}       # → { user_id, blocked: true, since }   idempotent
DELETE /v1/directory/blocks/{user_id}       # → { user_id, blocked: false }

A block freezes the direct conversation between the two users for both: sends, reactions, typing, broadcasts and the RTC join answer blocked (403), the history stays readable, and opening a new direct conversation between them is refused the same way. The blocked user cannot add the blocker to a group (they land in not_added with reason blocked); shared groups are unaffected. The blocked person is not told — render blocked on the blocker's side as "you blocked this contact". A list holds at most 1,000 users.

7 · The inbox #

The chat list, in one request: peer or group name, last-message preview, unread count, your own mute, newest first.

GET /v1/directory/inbox?limit=50
{
  "conversations": [
    { "room_id": "0f5c…", "kind": "direct", "name": null,
      "peer": { "user_id": "…", "display_name": "Bob", "phone": "+1…", "avatar_version": "2026-10-07T09:12:40Z" },
      "unread_count": 3,
      "muted_until": null,
      "last_message": { "event_seq": 4131, "sender_id": "…", "kind": "text",
                        "preview": "see you at six", "created_at": "…" } }
  ]
}

8 · Devices #

Register each device's push token so the user can be reached when offline — see Push & ringing. Tokens are stamped with the app they were registered under, which selects the right FCM/APNs credentials at send time.

What it does not do #

  • No self-serve account deletion yet. To erase a user, your server calls POST /v1/server/principals/{user_id}/erase, which also removes their directory identity.
  • No end-to-end encryption for directory conversations: direct and group conversations are created as ordinary unencrypted rooms, with no option to ask for E2EE. An encrypted room today has to be created on the server plane.
  • No session refresh/rotation. A session lasts 30 days and is then re-earned by a fresh OTP — but it is revocable: POST /v1/directory/logout ends the presented session, or every session for that user with {"all_devices": true}, answering {"logged_out": true, "sessions_revoked": n}. Call it on sign-out and on "log out everywhere".
  • No report or ban: mute and block are what a user has against another user.
  • Credentials you store (SMS vendor, FCM, APNs) are held in plaintext at rest, matching the rest of the platform's secret posture. There is no encryption-at-rest layer yet.