openapi: 3.0.3
info:
title: Obscura Server API
description: |
A privacy-first messaging relay.
- **Control Plane (REST):** Registration, Key Management, Sending.
- **Data Plane (WebSocket):** Real-time message delivery and acknowledgement.
- **Protocol:** Protocol Buffers (application/x-protobuf) are used for message bodies to minimize metadata leakage and binary bloat.
**Protobuf Definitions:**
The WebSocket and Message schemas are defined in the [obscura-proto](https://github.com/barrelmaker97/obscura-proto/blob/main/obscura/v1/obscura.proto) repository.
version: 0.0.0
license:
name: GPL-3.0-or-later
url: https://www.gnu.org/licenses/gpl-3.0.html
security:
- bearerAuth: []
paths:
/v1/users:
post:
operationId: registerUser
summary: Register a new user device.
tags: [Account]
security: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RegistrationRequest'
responses:
'201':
description: Account created.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'400':
$ref: '#/components/responses/BadRequestError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'409':
description: Username already exists.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/sessions:
post:
operationId: login
summary: Login and retrieve a session token.
tags: [Account]
security: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Authenticated.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
operationId: logout
summary: Logout (Revoke Refresh Token).
description: |
Revokes the provided Refresh Token, effectively logging the user out of that session.
tags: [Account]
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LogoutRequest'
responses:
'200':
description: Session revoked.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/sessions/refresh:
post:
operationId: refreshToken
summary: Refresh Access Token.
description: |
Exchanges a valid Refresh Token for a new pair (Access Token + Refresh Token).
Implements Rotation: The old Refresh Token is invalidated immediately.
tags: [Account]
security: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshRequest'
responses:
'200':
description: Tokens refreshed.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/keys:
post:
operationId: uploadKeys
summary: Upload PreKeys or Perform Device Takeover.
description: |
Uploads new Signed PreKeys and One-Time PreKeys.
**Device Takeover:**
If an `identityKey` is provided and it *differs* from the stored key for this user:
- Replaces the Identity Key.
- Deletes ALL old keys (Signed and One-Time).
- Deletes ALL pending messages.
- Disconnects active WebSockets.
If `identityKey` matches the stored key or is omitted, it acts as a standard key refill (appending new keys).
tags: [Keys]
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PreKeyUploadRequest'
responses:
'200':
description: Keys updated or takeover successful.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/keys/{userId}:
get:
operationId: getPreKeyBundle
summary: Fetch PreKey Bundle.
description: |
Returns active PreKey bundle for the target user.
Server atomically consumes one One-Time PreKey (if available).
tags: [Keys]
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: PreKey Bundle.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/PreKeyBundleResponse'
'404':
$ref: '#/components/responses/NotFoundError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/messages:
post:
operationId: sendMessages
summary: Send one or more encrypted messages.
description: |
Pushes an array of encrypted envelopes to target devices' queues.
Accepts a batch of messages to support multi-device fan-out or single messages.
**Idempotency:** Requires an `Idempotency-Key` header to safely retry dropped network requests.
**Payload:** `SendMessageRequest` (Protobuf).
**Response:** `SendMessageResponse` (Protobuf) detailing any partial failures. An empty response array indicates total success.
tags: [Messaging]
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
description: Client-generated UUID. Used to prevent duplicate processing on network retries.
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/x-protobuf:
schema:
type: string
format: binary
description: Serialized `SendMessageRequest` protobuf.
responses:
'200':
description: Batch processed. Parse the response payload to check for partial failures via the `failed_messages` array.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/x-protobuf:
schema:
type: string
format: binary
description: Serialized `SendMessageResponse` protobuf.
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'413':
description: Payload Too Large. The batch contains too many messages (exceeds server max).
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/gateway:
get:
operationId: connectGateway
summary: Connect to the Message Stream.
description: |
**WebSocket Endpoint.**
Clients must connect here to receive messages.
- **Protocol:** `WebSocketFrame` (Protobuf).
- **Auth:** Pass the JWT in the query string: `ws://.../v1/gateway?token=<jwt>`.
- **Handshake:** Server validates the JWT and ensures the user has an Identity Key stored.
- **Welcome:** Upon successful connection, the server may immediately push a `PreKeyStatus` frame if the user's one-time pre-key count is below the configured threshold.
- **Flow:** Server pushes `Envelope` frames. Client MUST respond with `AckMessage` frames. Server batches deletions based on ACKs.
tags: [Messaging]
security:
- tokenAuth: []
parameters:
- name: token
in: query
required: true
schema:
type: string
description: JWT Access Token.
responses:
'101':
description: Switching Protocols.
'401':
$ref: '#/components/responses/UnauthorizedError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/attachments:
post:
operationId: uploadAttachment
summary: Upload an attachment.
description: |
Uploads an encrypted binary blob to long-term storage.
Blobs are automatically deleted after a configured period.
Maximum and minimum size limits are enforced by the server.
tags: [Attachments]
security:
- bearerAuth: []
requestBody:
content:
application/octet-stream:
schema:
type: string
format: binary
minLength: 1
responses:
'201':
description: Upload successful.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/AttachmentResponse'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'411':
$ref: '#/components/responses/LengthRequiredError'
'413':
$ref: '#/components/responses/PayloadTooLargeError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/attachments/{id}:
get:
operationId: downloadAttachment
summary: Download an attachment.
tags: [Attachments]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: If-None-Match
in: header
required: false
schema:
type: string
description: The unique identifier for this attachment (from ETag).
responses:
'200':
description: Binary file stream.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
ETag:
description: The unique identifier for this attachment version.
schema:
type: string
content:
application/octet-stream:
schema:
type: string
format: binary
'304':
description: Attachment not modified (client already has the latest version).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
$ref: '#/components/responses/NotFoundError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/backup:
get:
operationId: getBackup
summary: Download the latest backup.
description: |
Streams the latest encrypted backup blob.
Supports conditional caching via `If-None-Match`.
Returns `ETag` header containing the version number.
tags: [Backup]
security:
- bearerAuth: []
parameters:
- name: If-None-Match
in: header
required: false
schema:
type: string
description: The current version held by the client (e.g. from ETag).
responses:
'200':
description: Binary backup stream.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
ETag:
description: Current backup version.
schema:
type: string
content:
application/octet-stream:
schema:
type: string
format: binary
'304':
description: Backup not modified (client already has the latest version).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
$ref: '#/components/responses/NotFoundError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
head:
operationId: headBackup
summary: Check for backup existence.
description: |
Returns metadata (Content-Length, ETag) without the body.
Useful for checking if a local backup is out of date.
tags: [Backup]
security:
- bearerAuth: []
responses:
'200':
description: Backup exists.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
ETag:
description: Current backup version.
schema:
type: string
Content-Length:
schema:
type: integer
format: int32
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
$ref: '#/components/responses/NotFoundError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
post:
operationId: uploadBackup
summary: Upload a new backup.
description: |
Uploads a new encrypted backup blob.
Uses Optimistic Locking via headers to prevent concurrent overwrites.
**Flow:**
1. **Initial Upload:** Client MUST send `If-None-Match: *`.
2. **Update:** Client MUST send `If-Match` with the latest version number (as received in a previous `ETag`).
3. If the preconditions match and no other upload is in progress, the server accepts the stream.
4. If the version does not match or resource already exists when `*` is used, server returns `412 Precondition Failed`.
5. If another upload is already in progress, server returns `409 Conflict`.
tags: [Backup]
security:
- bearerAuth: []
parameters:
- name: If-Match
in: header
required: false
schema:
type: string
description: Current version held by the client (from ETag). Required for updates.
- name: If-None-Match
in: header
required: false
schema:
type: string
description: Set to "*" for the first upload to ensure no backup exists. Required for initial upload.
requestBody:
content:
application/octet-stream:
schema:
type: string
format: binary
minLength: 32
maxLength: 2097152
responses:
'200':
description: Upload successful.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
ETag:
description: The new backup version.
schema:
type: string
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'409':
$ref: '#/components/responses/ConflictError'
'411':
$ref: '#/components/responses/LengthRequiredError'
'412':
$ref: '#/components/responses/PreconditionFailedError'
'413':
$ref: '#/components/responses/PayloadTooLargeError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
/v1/push-tokens:
put:
operationId: updatePushToken
summary: Register or update a push notification token.
description: |
Associates an FCM or APNS token with the authenticated user device for wake-up signals.
Obscura uses a "Single Device" policy; updating the token replaces any existing one.
tags: [Push Notifications]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterPushTokenRequest'
responses:
'200':
description: Token registered successfully.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
'401':
$ref: '#/components/responses/UnauthorizedError'
'408':
$ref: '#/components/responses/RequestTimeoutError'
'400':
$ref: '#/components/responses/BadRequestError'
'429':
$ref: '#/components/responses/TooManyRequestsError'
'500':
$ref: '#/components/responses/InternalServerError'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
tokenAuth:
type: apiKey
in: query
name: token
headers:
x-request-id:
description: Unique identifier for the request, used for tracing and debugging.
schema:
type: string
format: uuid
retry-after:
description: The number of seconds to wait before retrying the request.
schema:
type: string
responses:
UnauthorizedError:
description: Unauthorized (Invalid or missing token).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
BadRequestError:
description: Invalid input, malformed request, or missing required headers (e.g. version or length).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
NotFoundError:
description: The requested resource was not found.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
ConflictError:
description: Conflict (e.g., concurrent upload already in progress).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
PreconditionFailedError:
description: Precondition Failed (Version mismatch or resource already exists).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
RequestTimeoutError:
description: Request Timeout (The server timed out waiting for the request).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
LengthRequiredError:
description: Length Required (Content-Length header missing).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
PayloadTooLargeError:
description: Payload Too Large (Upload exceeds maximum size limit).
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
TooManyRequestsError:
description: Rate limit exceeded.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
retry-after:
$ref: '#/components/headers/retry-after'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
InternalServerError:
description: Internal Server Error.
headers:
x-request-id:
$ref: '#/components/headers/x-request-id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
schemas:
ErrorResponse:
type: object
required: [error]
properties:
error:
type: string
description: Semantic error message.
AuthResponse:
type: object
required:
- token
- refreshToken
- expiresAt
properties:
token:
type: string
description: Short-lived JWT Access Token.
refreshToken:
type: string
description: Long-lived opaque Refresh Token.
expiresAt:
type: integer
format: int64
description: UNIX timestamp of access token expiration.
LoginRequest:
type: object
required:
- username
- password
properties:
username:
type: string
minLength: 1
maxLength: 50
password:
type: string
minLength: 12
LogoutRequest:
type: object
required: [refreshToken]
properties:
refreshToken:
type: string
RefreshRequest:
type: object
required: [refreshToken]
properties:
refreshToken:
type: string
RegisterPushTokenRequest:
type: object
required: [token]
properties:
token:
type: string
description: FCM or APNS device token.
RegistrationRequest:
type: object
required:
- username
- password
- identityKey
- registrationId
- signedPreKey
- oneTimePreKeys
properties:
username:
type: string
minLength: 1
maxLength: 50
password:
type: string
minLength: 12
identityKey:
type: string
format: byte
description: Base64 encoded public identity key.
registrationId:
type: integer
format: int32
signedPreKey:
$ref: '#/components/schemas/SignedPreKey'
oneTimePreKeys:
type: array
items:
$ref: '#/components/schemas/OneTimePreKey'
PreKeyUploadRequest:
type: object
required:
- signedPreKey
- oneTimePreKeys
properties:
identityKey:
type: string
format: byte
description: Optional Base64 Identity Key. Triggers takeover if changed.
registrationId:
type: integer
format: int32
description: Required if identityKey is provided.
signedPreKey:
description: Signed Pre-Key. ID must be strictly greater than the current stored ID.
allOf:
- $ref: '#/components/schemas/SignedPreKey'
oneTimePreKeys:
type: array
items:
$ref: '#/components/schemas/OneTimePreKey'
PreKeyBundleResponse:
type: object
properties:
registrationId:
type: integer
format: int32
identityKey:
type: string
format: byte
signedPreKey:
$ref: '#/components/schemas/SignedPreKey'
oneTimePreKey:
type: object
nullable: true
allOf:
- $ref: '#/components/schemas/OneTimePreKey'
SignedPreKey:
type: object
required:
- keyId
- publicKey
- signature
properties:
keyId:
type: integer
format: int32
publicKey:
type: string
format: byte
description: Base64 encoded public key
signature:
type: string
format: byte
description: Base64 encoded signature
OneTimePreKey:
type: object
required:
- keyId
- publicKey
properties:
keyId:
type: integer
format: int32
publicKey:
type: string
format: byte
description: Base64 encoded public key
AttachmentResponse:
type: object
properties:
id:
type: string
format: uuid
expiresAt:
type: integer
format: int64
description: UNIX timestamp of when the file will be deleted.