# Syncube API Documentation > Syncube is a secure digital container platform. Create encrypted containers, upload files, set release conditions (instant, time-based, dead man's switch, or multi-condition), and deliver contents via email, webhook, Slack, Teams, or Discord. Last updated: 2026-04-28. All timestamps are Unix milliseconds UTC unless otherwise noted. ## API Surfaces Syncube exposes three distinct HTTPS surfaces. They use different auth and serve different callers; do not mix them in one request flow. | Surface | Base URL | Auth | Used by | |---------|----------|------|---------| | LLM API | `https://llm.syncube.tech/v1` | `X-API-Key` header | Your API key — all endpoints in this document | | Public release API | `https://api.syncube.tech/v1/public/{token}/...` | URL token (from webhook payload) | Recipients fetching a released container — no API key | | Token bootstrap | `https://llm.syncube.tech/v1/register`, `/claim` | None | Anonymous registration via crypto payment | ## Base URL ``` https://llm.syncube.tech/v1 ``` ## Authentication All requests require an API key in the `X-API-Key` header: ``` X-API-Key: YOUR_API_KEY ``` Generate an API key from your Syncube dashboard at https://syncube.tech (API Keys page). Alternatively, obtain a disposable API key without registration using the Token Access flow (see below). Containers created via the LLM API are **always sealed** — the server-side handler forces `sealed: true` for every API-key-authenticated create. Sealed containers cannot be deactivated, unsealed, or edited (background image is the one exception) once activated. They can still be deleted, but deletion orphans the on-chain vault. Plan for this before activating. ## Rate Limits - 50 requests per second - Burst limit of 25 - On exceed, API Gateway returns HTTP 429 with `{"message": "Too Many Requests"}`. There is no `Retry-After` header — back off ≥1 second and retry. ## Idempotency The API does **not** support idempotency keys. Do not blind-retry POST/PUT/DELETE on network errors: - Retrying `POST /containers` mints a second container and charges credits twice. - Retrying `POST /containers/{id}/files` returns a fresh presigned URL but only deducts credits once per call (each call deducts). - `POST /containers/{id}/activate` is safe to retry only when it returned a network error before any 2xx/4xx response. A returned 4xx or 2xx is final. If unsure whether a write succeeded, call `GET /containers/{id}` to check. ## Endpoints ### Containers | Method | Path | Credits | Description | |--------|------|---------|-------------| | GET | /containers | free | List all containers | | POST | /containers | 10 | Create a new container (with optional vault creation) | | GET | /containers/{id} | free | Get a specific container | | PUT | /containers/{id} | 10 if `releaseConditions` change, else free | Update a container's properties (only on idle/created/broken/aborted; sealed containers reject all edits) | | DELETE | /containers/{id} | free | Delete a container and its contents | ### Files | Method | Path | Credits | Description | |--------|------|---------|-------------| | GET | /containers/{id}/files | free | List all files in a container | | POST | /containers/{id}/files | size-based | Upload a file via single PUT presigned URL (use for files ≤ ~100 MB) | | POST | /containers/{id}/files/multipart | size-based | Resumable chunked upload (use for files > ~100 MB; required for files > 5 GB due to S3's single-PUT limit) | | POST | /containers/{id}/files/lock | free | Acquire / heartbeat / release the upload lock for a single-PUT upload | | GET | /containers/{id}/files/{fileId} | free | Download a file (returns presigned URL) | | DELETE | /containers/{id}/files/{fileId} | free | Delete a file | ### Encryption Keys | Method | Path | Credits | Description | |--------|------|---------|-------------| | POST | /containers/{id}/keys | free | Store wrapped DEK for a container | | GET | /containers/{id}/keys | free | Retrieve wrapped DEK for a container | ### Container Lifecycle | Method | Path | Credits | Description | |--------|------|---------|-------------| | POST | /containers/{id}/activate | 10 | Activate container (starts release countdown) | | POST | /containers/{id}/deactivate | free | Deactivate container. **Always returns 403 for API-key-created containers** (they are forced sealed); usable only on dashboard-created containers accessed via API key on the same account. | | POST | /containers/{id}/checkin | 1 | Check in on a periodic container (resets deadline) | ### Billing | Method | Path | Credits | Description | |--------|------|---------|-------------| | GET | /billing/balance | free | Get current credit balance | | POST | /billing/topup | free | Purchase additional credits | ### Token Access (No Auth Required) | Method | Path | Credits | Description | |--------|------|---------|-------------| | POST | /register | free | Register for a disposable API key via crypto payment | | POST | /claim | free | Claim your API key after payment | ## Errors All error responses share this body shape: ```json { "error": "Human-readable message", "code": "OPTIONAL_MACHINE_CODE" } ``` The `code` field is present for a small set of error classes that frontends gate on; most errors return only `error`. Treat `error` as a message safe to log and `code` as the programmatic discriminator when present. ### Status code map | Status | Meaning | Action | |--------|---------|--------| | 400 | Invalid parameters, malformed body, validation failure (bad `releaseTime`, missing fields, unsupported field, unknown condition/action type, vault setup incomplete, no files in container, etc.) | Fix the request — do not retry | | 402 | `Insufficient credits` — your plan + purchased credits cannot cover the operation | Top up via `/billing/topup` | | 403 | Forbidden. Common forms: `SUBSCRIPTION_EXPIRED` (expired tier cannot write), `SEALED_IMMUTABLE` (cannot edit a sealed container), `Container is activated and cannot be modified` (file upload on activated container) | Don't retry; container/account state must change first | | 404 | Container, file, or token not found, or not owned by your API key | Don't retry | | 409 | Conflict. Common forms: `Uploads are still in progress for this container` (active upload locks block activate / seal / update / delete), `Container status changed, please retry` (race during activate), `Container already has a vault` (race during setupVault), `Invalid or unclaimable token` from `/claim` if token already claimed or in wrong state | Wait for the conflicting state to clear, then retry | | 429 | Rate limit exceeded (50 req/s, burst 25) | Back off ≥1s and retry | | 500 | Server-side failure (vault RPC, DynamoDB, blockchain) | Safe to retry once after a delay; if persistent, treat as outage | | 503 | `Service is under maintenance` | Pause and retry later | ### Common error code strings When `code` is set, expect one of: | `code` | Status | When | |--------|--------|------| | `SUBSCRIPTION_EXPIRED` | 403 | Account is in `expired_tier`; read-only | | `SEALED_IMMUTABLE` | 403 | Update attempted on a sealed container | Examples of unrecognized but useful `error` strings (machine-stable enough to grep on): - `Insufficient credits` - `Uploads are still in progress for this container` - `Container status changed, please retry` - `Vault not found on-chain` - `Cannot activate a released vault` - `Container has no encryption keys` - `Vault setup incomplete: release keys missing` - `At least one release action is required` - `vaultSignature required to modify release actions on vault containers` - `Release date must be at least 1 hour from now` - `intervalDays must be a number between 1 and 365` ## Container States Every container has a `status` field. State transitions are server-driven; the API rejects operations that don't match the current state. | Status | Meaning | |--------|---------| | `created` | Just created, no files yet | | `idle` | Has files and wrapped keys, ready to activate | | `activated` | Release countdown running (or paid container awaiting purchase) | | `updating` | Saga is wiring up scheduled release on EventBridge — transient, becomes `activated` | | `released` | Release conditions met; recipients have been notified | | `claimed` | A recipient has accessed the public link | | `broken` / `aborted` | Saga failure; container is editable for recovery | | `testing` | Test-release flow in progress | ### Allowed operations per state | Operation | Allowed states | |-----------|----------------| | `PUT /containers/{id}` (full edit) | `created`, `idle`, `broken`, `aborted` (and `testing` if background-only). **Always rejected on sealed containers** regardless of state, except for the seal operation itself and the `setupVault` flow. | | `POST /containers/{id}/files` (upload) | `created`, `idle` | | `DELETE /containers/{id}/files/{fileId}` | `created`, `idle` | | `POST /containers/{id}/files/lock` (acquire/heartbeat) | `created`, `idle` (release allowed always) | | `POST /containers/{id}/activate` | `created`, `idle` (requires ≥1 file, wrapped keys present, no active upload locks) | | `POST /containers/{id}/deactivate` | `activated` only, and **never** on sealed containers (LLM API containers are always sealed → deactivate always returns 403) | | `POST /containers/{id}/checkin` | `activated`, only on containers with a `periodic_checkin` condition | | `DELETE /containers/{id}` | Any state, but cascade behavior depends on state | ### Activation preconditions Calling `POST /containers/{id}/activate` will return 400 unless ALL of: - Status is `idle` or `created` - At least one file uploaded to S3 - Wrapped keys saved via `POST /containers/{id}/keys` (sealed containers need `releaseWrappedDEK`; non-sealed need both `wrappedDEK` and `releaseWrappedDEK`) - For containers with conditions: at least one release action present - For vault containers: vault exists on-chain and is not already released - No active upload locks (returns 409) Activate deducts 10 credits **before** the on-chain vault activation. If the on-chain step fails, credits are auto-refunded and the container stays in its prior state. For paid containers, activation cost is computed dynamically as `clamp(ceil(price_usd * 0.05 * 10000), 5000, 10_000_000)` credits. ## POST /containers — Create Container Creates a new container. When `releaseConditions` includes a `time_based` or `periodic_checkin` condition, a blockchain vault is automatically created and the response includes a `releasePassword` for encrypting files. ### Request Body | Parameter | Required | Type | Description | |-----------|----------|------|-------------| | name | yes | string | Container name, max 100 characters | | description | no | string | Container description, max 500 characters | | releaseConditions | yes* | array | Release settings and actions (see examples below). *Required for API containers. | | hidden | no | boolean | Hide container from public listings | The `releaseConditions` array contains one or more condition objects with: - `type` — one of `"instant"`, `"time_based"`, `"periodic_checkin"`, or `"paid"` (marketplace sale) - `settings` — array of condition settings (see examples and the Field Constraints section) Multiple conditions use OR logic — whichever fires first triggers release. `"instant"` and `"paid"` cannot be combined with any other condition. The `releaseActions` array (top-level) defines what happens on release: - `email_notification` — send email with release link and password - `webhook` — POST JSON payload to any HTTPS URL - `slack` — Slack Block Kit message (URL must be `hooks.slack.com`) - `teams` — Teams Adaptive Card (URL must be `*.webhook.office.com` or `*.logic.azure.com`) - `discord` — Discord embed (URL must be `discord.com/api/webhooks/` or `discordapp.com/api/webhooks/`) At least one release action is required when `releaseConditions` is non-empty (paid containers are the exception — they take no actions). At most one action of each type is allowed per container. ### Response ```json { "containerId": "uuid", "status": "created", "creditsDeducted": 10, "releasePassword": "0xacb8a8a80df18db9ad2a4f69832874a09a7772c157c52c6ab5eff09d7f08d03e", "publicUrl": "https://www.syncube.tech/public/" } ``` The `releasePassword` field is returned when the container has a release condition. Save it to encrypt your files, then discard it. The password is generated inside a blockchain vault and cannot be retrieved until release. The `publicUrl` field is returned only for paid containers (the URL buyers visit to purchase access). ## Field Constraints These limits are enforced server-side; violations return 400 with the noted message. ### Container fields | Field | Limit | |-------|-------| | `name` | 1–100 characters, trimmed; required | | `description` | ≤500 characters | | `hidden` | Boolean; hidden containers cannot use `periodic_checkin` and require ≥1 release action to activate | ### `releaseConditions[].type` enum `instant` | `time_based` | `periodic_checkin` | `paid` Combination rules: - `instant` cannot be combined with any other condition. - `paid` cannot be combined with any other condition. - At most one `time_based`. - At most one `periodic_checkin`. ### `time_based` setting - `setting.type` must equal `"datetime"`. - `setting.values[].releaseTime` must be a positive number of milliseconds since epoch, **at least 1 hour in the future**. ### `periodic_checkin` setting - `setting.type` must equal `"interval"`. - `setting.values[0].intervalDays` is required, integer in `[1, 365]`. - Optional `reminderEnabled` (boolean). If true, `reminderTimeBefore` must be in `[1_800_000, 604_800_000]` ms (30 min – 7 days) and ≤ `intervalDays * 86_400_000 - 3_600_000`. - Optional `emailCheckinEnabled` (boolean). ### `paid` setting (paid containers) - `price` (USD): number in `[1, 999_999]`, max 2 decimals. - `currency`: must be `"usd"` if provided. - `acceptCrypto`: must be `true`. - `ownerWalletAddress`: required, validated against `ownerWalletChain` (default `"evm"`). ### `releaseActions[].type` enum and URL rules | `type` | URL constraint | |--------|----------------| | `email_notification` | Each `values[].email` must match `^[^\s@]+@[^\s@]+\.[^\s@]{2,}$` | | `webhook` | HTTPS only; hostname must not resolve to a private/reserved IP (RFC 1918, loopback, link-local, AWS metadata) | | `slack` | HTTPS, hostname exactly `hooks.slack.com` | | `teams` | HTTPS, hostname ends with `.webhook.office.com` or `.logic.azure.com` | | `discord` | HTTPS, hostname `discord.com` or `discordapp.com`, path starts with `/api/webhooks/` | ### File upload - `fileName` is required (or pre-known `fileId` for re-signing in-flight uploads). - `fileSize` is in bytes; credit cost is `max(1, ceil(fileSize / 1MB))` credits. - All uploads must be encrypted (AES-256-GCM) before PUT to the presigned URL. The `s3_file_validator` Lambda checks magic bytes + entropy on every `.enc` upload and **deletes anything that looks like plaintext**. Credits are not refunded for rejected uploads. ## Pagination `GET /containers` and `GET /containers/{id}/files` return the **full list in a single response** — no cursor, no `limit`, no `offset` parameters. The list endpoint returns `{ total, items, size }` where `total` is the count of `items` and `size` is total bytes used across all containers (hidden ones still count toward this number). If you have more than ~1000 containers and the response approaches DynamoDB's 1 MB query ceiling, contact support — paginated listing is not currently exposed. ## Resumable / Large File Uploads `POST /containers/{id}/files` returns a single S3 presigned PUT URL — fine for files up to ~100 MB. For larger files, or for resumable uploads that need to survive network interruptions, use `POST /containers/{id}/files/multipart` instead. S3's single-PUT limit is 5 GB; anything larger **must** use multipart. ### Multipart upload Body shape: `{ action, fileId, ...action-specific }`. | `action` | Required body fields | Returns | |----------|---------------------|---------| | `initiate` | `fileId` (32-char hex you generate), `fileSize` (bytes) | `{ uploadId, fileId, creditsDeducted, lockTtlSeconds }` | | `sign-parts` | `fileId`, `uploadId`, `partNumbers` (array of 1-based ints, max 100 per call) | `{ urls }` — array of presigned PUT URLs, one per part, in order | | `complete` | `fileId`, `uploadId`, `parts` (array of `{ PartNumber, ETag }` from S3 PUT responses) | `{ success: true }` | | `abort` | `fileId`, `uploadId` | `{ success: true }` | Flow: 1. Call `initiate` to start the upload and get an `uploadId`. **Credits are deducted here** (`max(1, ceil(fileSize / 1MB))`); the lock is acquired. 2. Split the encrypted file into 5 MB – 5 GB chunks. Call `sign-parts` with batches of part numbers (≤ 100 per call) to get presigned URLs. Each `sign-parts` call also heartbeats the lock — call it at least every ~3 minutes for long uploads. 3. PUT each chunk to its presigned URL with `Content-Type: application/octet-stream`. Capture the `ETag` from each S3 response. 4. Call `complete` with the full list of `{ PartNumber, ETag }` to finalize the upload. The lock is released. 5. On failure or cancel, call `abort` to discard the multipart upload and release the lock. If you skip step 4 or 5, the lock expires after 5 minutes (TTL) and S3 multipart parts linger until the bucket lifecycle rule purges them. Always abort on cancel. ### Single-PUT lock If you use `POST /containers/{id}/files` (single PUT) for a file that takes longer than the 5-minute lock TTL, you need to heartbeat the lock yourself via `POST /containers/{id}/files/lock`: | `action` | Body | When to call | |----------|------|--------------| | `acquire` | `{ action: "acquire", fileId }` | If you need the lock before requesting a presigned URL (rare — `POST /containers/{id}/files` already acquires one) | | `heartbeat` | `{ action: "heartbeat", fileId }` | Every ≤ 60 seconds for any single-PUT upload that may take more than 5 minutes (uploading via slow connection) | | `release` | `{ action: "release", fileId }` | After the PUT completes (success, failure, or cancel) | Returns `{ action, ttlSeconds }` for `acquire`/`heartbeat`. The lock TTL is 300 seconds; the backend tolerates a 3-minute stall, so a 60-second heartbeat is comfortable. While any active lock exists on the container, the following calls return 409 `Uploads are still in progress for this container`: `activate`, `update` (when sealing or changing conditions/actions), `delete` (container or file), and any password-mode change. ## Discoverability & Tooling - **OpenAPI 3.1 spec**: `https://syncube.tech/openapi.yaml` — machine-readable description of every endpoint in this document, suitable for codegen (openapi-generator, oazapfts, etc.) and for tool-using LLMs that consume OpenAPI directly. The spec covers only `llm.syncube.tech/v1`; it does not describe the dashboard or business APIs. This document remains the prose reference. - **Webhook testing**: there is no built-in test endpoint. The simplest way to verify webhook delivery is to use [webhook.site](https://webhook.site) — open the page, copy the unique URL, set it as your `webhook` action's `webhookUrl`, then trigger a release (an `instant` container is the fastest: create + activate fires the webhook immediately, costs 20 credits). webhook.site shows the full request including headers, body, and timing. - **No SDK**: there is no official client library. Use HTTPS directly, generate one from the OpenAPI spec, or use the MCP server (below) which wraps every endpoint into a tool call. ## POST /containers/{id}/files — Upload File ### Request Body | Parameter | Required | Type | Description | |-----------|----------|------|-------------| | fileName | yes | string | Name of the file to upload | | fileSize | no | number | File size in bytes (used for credit calculation; rounded up to nearest MB) | ### Response ```json { "url": "presigned-upload-url", "uploadUrl": "presigned-upload-url", "fileId": "generated-file-id", "creditsDeducted": 1 } ``` Upload the encrypted file to the returned `uploadUrl` via PUT with `Content-Type: application/octet-stream`. ## GET /containers — List Response ```json { "total": 2, "items": [ { "containerId": "uuid", "name": "string", "description": "string", "status": "idle", "sealed": true, "hidden": false, "usedSize": 1048576, "vaultId": "0x...", "vaultConditionType": "time_based", "releaseConditions": [...], "releaseActions": [...], "paidConfig": null, "urlToken": "..." } ], "size": 2097152 } ``` `urlToken` is only included for paid or already-released containers (it's the public-link slug). ## GET /containers/{id} — Response ```json { "containerId": "uuid", "name": "string", "description": "string", "status": "idle", "sealed": true, "hidden": false, "usedSize": 1048576, "vaultId": "0x...", "vaultConditionType": "time_based", "releaseConditions": [...], "releaseActions": [...], "paidConfig": null, "hasKeys": true, "hasManualPassword": false, "urlToken": "..." } ``` `hasKeys` is `true` once wrapped DEK has been saved via `POST /containers/{id}/keys` (sealed containers require `releaseWrappedDEK`; non-sealed require both `wrappedDEK` and `releaseWrappedDEK`). Activate will reject the container until `hasKeys === true`. ## GET /billing/balance — Response ```json { "planCredits": 10500, "purchasedCredits": 5000, "total": 15500 } ``` ## POST /billing/topup — Purchase Credits ### Request Body | Parameter | Required | Type | Description | |-----------|----------|------|-------------| | credits | yes | number | Credit package to purchase. Must be one of: `10000`, `50000`, `100000`, `200000`, `500000` | ### Credit Packages | Credits | Price (USD) | |---------|-------------| | 10,000 | $1 | | 50,000 | $5 | | 100,000 | $10 | | 200,000 | $20 | | 500,000 | $50 | ### Response ```json { "invoiceUrl": "https://nowpayments.io/payment/...", "credits": 10000, "price": 5 } ``` Payment is processed via cryptocurrency (NOWPayments). Credits are added after payment confirmation. ## Token Access — Registration-Free API Keys Get an API key without creating an account. Pay with crypto, get a key, use it until credits run out. > **Pricing note:** the no-account bootstrap is 5× more expensive per credit than `/billing/topup`. Bootstrap charges $5 per 10,000 credits ($0.0005/credit); `/billing/topup` charges $1 per 10,000 credits ($0.0001/credit). Use bootstrap only when you genuinely need an anonymous key — once you have one, top up via `/billing/topup` at the cheaper rate. ### Step 1: Register `POST https://llm.syncube.tech/v1/register` (no auth) ```json { "credits": 10000 } ``` **Response:** ```json { "invoiceUrl": "https://nowpayments.io/payment/...", "claimToken": "a1b2c3d4...64-char-hex-string", "credits": 10000, "price": 5 } ``` Credit packages: 10,000 ($5) | 20,000 ($10) | 50,000 ($25) | 100,000 ($50) ### Step 2: Pay Open `invoiceUrl` and complete the crypto payment. ### Step 3: Claim API Key `POST https://llm.syncube.tech/v1/claim` (no auth) ```json { "claimToken": "a1b2c3d4...64-char-hex-string" } ``` **Response (payment pending):** ```json { "status": "pending" } ``` **Response (payment confirmed):** ```json { "apiKey": "sk_live_...", "keyPrefix": "sk_live_a1b", "userId": "uuid", "credits": 10000 } ``` Save the `apiKey` — it cannot be retrieved again. Use it in the `X-API-Key` header for all subsequent API calls. The `keyPrefix` is a non-secret 12-char prefix you can store for display ("which key am I using?"); never log the full `apiKey`. **Response (already claimed or wrong state):** HTTP 409 with `{ "error": "Invalid or unclaimable token" }`. Each `claimToken` can be claimed exactly once. Token-tier accounts have **0 plan credits** — they only have the credits you purchased. There is no monthly refresh; storage and per-container maintenance fees are billed monthly against your purchased balance. ### Step 4: Top Up When credits run low, purchase more via the `/billing/topup` endpoint using your API key (no new registration needed). ## Examples List containers: ```bash curl https://llm.syncube.tech/v1/containers \ -H "X-API-Key: YOUR_API_KEY" ``` Create a time-based release with email notifications: ```bash curl -X POST https://llm.syncube.tech/v1/containers \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Time Capsule", "description": "Opens on New Year 2027", "releaseConditions": [{ "type": "time_based", "settings": [{ "type": "datetime", "values": [{ "releaseTime": 1798761600000 }] }] }], "releaseActions": [{ "type": "email_notification", "values": [ { "email": "recipient1@example.com" }, { "email": "recipient2@example.com" } ] }] }' # Returns: { "containerId": "...", "creditsDeducted": 10, "releasePassword": "0x..." } ``` Instant release (releases immediately on activation): ```bash curl -X POST https://llm.syncube.tech/v1/containers \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Instant Delivery", "releaseConditions": [{ "type": "instant", "settings": [] }], "releaseActions": [{ "type": "webhook", "values": [{ "webhookUrl": "https://your-server.com/webhook" }] }] }' ``` Periodic check-in (release if no check-in for 30 days): ```bash curl -X POST https://llm.syncube.tech/v1/containers \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Dead Man Switch", "releaseConditions": [{ "type": "periodic_checkin", "settings": [{ "type": "interval", "values": [{ "intervalDays": 30 }] }] }], "releaseActions": [{ "type": "email_notification", "values": [{ "email": "recipient@example.com" }] }] }' ``` Multi-condition (time-based + check-in, OR logic — whichever fires first): ```bash curl -X POST https://llm.syncube.tech/v1/containers \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Multi-Condition Container", "releaseConditions": [ { "type": "time_based", "settings": [{ "type": "datetime", "values": [{ "releaseTime": 1798761600000 }] }] }, { "type": "periodic_checkin", "settings": [{ "type": "interval", "values": [{ "intervalDays": 30 }] }] } ], "releaseActions": [ { "type": "email_notification", "values": [{ "email": "recipient@example.com" }] }, { "type": "slack", "values": [{ "webhookUrl": "https://hooks.slack.com/services/T.../B.../xxx" }] } ] }' ``` With platform webhooks (Slack, Teams, Discord): ```bash curl -X POST https://llm.syncube.tech/v1/containers \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Platform Notifications", "releaseConditions": [{ "type": "time_based", "settings": [{ "type": "datetime", "values": [{ "releaseTime": 1798761600000 }] }] }], "releaseActions": [ { "type": "slack", "values": [{ "webhookUrl": "https://hooks.slack.com/services/T.../B.../xxx" }] }, { "type": "teams", "values": [{ "webhookUrl": "https://xxx.webhook.office.com/webhookb2/..." }] }, { "type": "discord", "values": [{ "webhookUrl": "https://discord.com/api/webhooks/123/abc" }] } ] }' ``` ### Webhook Payload When a container is released, Syncube sends a POST request to your webhook URL: ```json { "event": "container_released", "containerId": "uuid", "webUrl": "https://www.syncube.tech/public/", "apiUrl": "https://api.syncube.tech/v1/public//access", "releasePassword": "vault-generated-password" } ``` - `webUrl` — browser link for human recipients - `apiUrl` — API endpoint for programmatic access (no auth required, token grants access) - `releasePassword` — password to decrypt the container files (omitted for non-vault legacy containers) ### Webhook Delivery Semantics - **Method**: HTTP POST, `Content-Type: application/json` - **User-Agent**: `Syncube-Webhook/1.0` - **Timeout**: 10 seconds per attempt - **Retries**: **None.** Each release fires the webhook once. If your endpoint times out, returns a 5xx, or refuses the connection, Syncube logs `WEBHOOK_DELIVERY_FAILED` against the container and moves on. Build your receiver to ack quickly (return 200 within 10s) and queue the work asynchronously. - **Source IPs**: outbound from AWS Lambda in `us-west-2`. Not pinned. Do not allowlist by IP. - **Signature**: webhook payloads are **not signed**. Treat the URL itself (and the `urlToken` embedded in `webUrl` / `apiUrl`) as the secret — anyone who knows the URL can fetch the container. Use a long, unguessable webhook URL on your side (e.g. include a per-container token in the path or query). - **TLS**: HTTPS is required; the receiver's certificate must be valid (no self-signed). Plain HTTP and private/reserved IPs (RFC 1918, loopback, link-local, AWS metadata) are rejected at container creation. - **Ordering**: a single release fires all configured `releaseActions` concurrently. Email and webhooks are independent — one failing does not block the others. ### Accessing a Released Container via API The public API endpoints use the token from the webhook (no API key needed): **1. Claim the container:** ```bash curl -X POST https://api.syncube.tech/v1/public/TOKEN/access ``` Returns `{ expiresAt }` — container is accessible for 7 days after first claim. **2. Get decryption keys:** ```bash curl https://api.syncube.tech/v1/public/TOKEN/keys ``` Returns `{ wrappedDEK, kekSalt, wrappedDEKIV }` — the encrypted file key, wrapped with the release password. **3. List files:** ```bash curl -X POST https://api.syncube.tech/v1/public/TOKEN/list ``` **4. Download a file:** ```bash curl -X POST https://api.syncube.tech/v1/public/TOKEN/file/download \ -H "Content-Type: application/json" \ -d '{"fileId": "FILE_ID"}' ``` Returns `{ url }` — a signed download URL for the encrypted file. **5. Decrypt files:** Use the `releasePassword` from the webhook payload to derive the KEK (using `kekSalt` from step 2), unwrap the DEK (`wrappedDEK` + `wrappedDEKIV`), then decrypt each downloaded file with AES-256-GCM. Get a container: ```bash curl https://llm.syncube.tech/v1/containers/CONTAINER_ID \ -H "X-API-Key: YOUR_API_KEY" ``` Update a container (only valid for dashboard-created, non-sealed containers; API-key-created containers are always sealed and will return 403 `SEALED_IMMUTABLE`): ```bash curl -X PUT https://llm.syncube.tech/v1/containers/CONTAINER_ID \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Updated Name", "description": "Updated description", "releaseConditions": [{ "type": "time_based", "settings": [{ "type": "datetime", "values": [{ "releaseTime": 1798761600000 }] }] }], "releaseActions": [{ "type": "email_notification", "values": [{ "email": "new-recipient@example.com" }] }] }' ``` Delete a container: ```bash curl -X DELETE https://llm.syncube.tech/v1/containers/CONTAINER_ID \ -H "X-API-Key: YOUR_API_KEY" ``` List files: ```bash curl https://llm.syncube.tech/v1/containers/CONTAINER_ID/files \ -H "X-API-Key: YOUR_API_KEY" ``` Upload a file (two-step process): ```bash # 1. Get presigned upload URL curl -X POST https://llm.syncube.tech/v1/containers/CONTAINER_ID/files \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fileName": "document.pdf.enc", "fileSize": 1048576}' # 2. Upload encrypted file to presigned URL curl -X PUT "PRESIGNED_URL" \ -H "Content-Type: application/octet-stream" \ --data-binary @encrypted_file.enc ``` Download a file: ```bash curl https://llm.syncube.tech/v1/containers/CONTAINER_ID/files/FILE_ID \ -H "X-API-Key: YOUR_API_KEY" ``` Delete a file: ```bash curl -X DELETE https://llm.syncube.tech/v1/containers/CONTAINER_ID/files/FILE_ID \ -H "X-API-Key: YOUR_API_KEY" ``` Activate a container: ```bash curl -X POST https://llm.syncube.tech/v1/containers/CONTAINER_ID/activate \ -H "X-API-Key: YOUR_API_KEY" ``` Deactivate a container (only valid for dashboard-created, non-sealed containers; API-key-created containers are always sealed and will return 403): ```bash curl -X POST https://llm.syncube.tech/v1/containers/CONTAINER_ID/deactivate \ -H "X-API-Key: YOUR_API_KEY" ``` Check in: ```bash curl -X POST https://llm.syncube.tech/v1/containers/CONTAINER_ID/checkin \ -H "X-API-Key: YOUR_API_KEY" ``` Get credit balance: ```bash curl https://llm.syncube.tech/v1/billing/balance \ -H "X-API-Key: YOUR_API_KEY" ``` Purchase credits: ```bash curl -X POST https://llm.syncube.tech/v1/billing/topup \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"credits": 10000}' ``` ## Sealed Container Flow (LLM-to-LLM) Sealed containers use a blockchain vault to generate and custody the release password. The vault is created automatically as part of container creation — no separate setup step needed. > All endpoints below use the LLM API (`llm.syncube.tech/v1`) with API key auth. ### Step 1: Create Container (vault created automatically) `POST https://llm.syncube.tech/v1/containers` (API key auth) ```json { "name": "Sealed Container", "description": "Time-locked sealed container", "releaseConditions": [{ "type": "time_based", "settings": [{ "type": "datetime", "values": [{ "releaseTime": 1772182800000 }] }] }], "releaseActions": [{ "type": "webhook", "values": [{ "webhookUrl": "https://your-server.com/webhook" }] }] } ``` **Response:** ```json { "containerId": "753c784b-ec02-4636-b300-6ad9c84eb38c", "status": "created", "creditsDeducted": 10, "releasePassword": "0xacb8a8a80df18db9ad2a4f69832874a09a7772c157c52c6ab5eff09d7f08d03e" } ``` The `releasePassword` is generated inside a blockchain vault on Oasis Sapphire. Save it to encrypt your files, then discard it. For periodic check-in containers, use `"type": "periodic_checkin"` with `intervalDays`: ```json { "name": "Dead Man Switch", "releaseConditions": [{ "type": "periodic_checkin", "settings": [{ "type": "interval", "values": [{ "intervalDays": 30 }] }] }], "releaseActions": [{ "type": "webhook", "values": [{ "webhookUrl": "https://your-server.com/webhook" }] }] } ``` ### Step 2: Encrypt and Upload Files Use the `releasePassword` from Step 1 to derive a KEK, generate a DEK, encrypt your files, and upload them. See the encryption examples below. 1. Generate a random DEK (AES-256-GCM key) 2. Encrypt each file with the DEK 3. Derive a KEK from the `releasePassword` using PBKDF2 with a random salt 4. Wrap (encrypt) the DEK with the KEK 5. Upload encrypted files via the `/containers/{id}/files` endpoint ### Step 3: Save Wrapped Keys `POST https://llm.syncube.tech/v1/containers/{containerId}/keys` ```json { "releaseWrappedDEK": "base64-encoded-wrapped-dek", "releaseKekSalt": "base64-encoded-salt", "releaseWrappedDEKIV": "base64-encoded-iv" } ``` These keys allow the platform to decrypt files on release using the vault password. ### Step 4: Activate the Container `POST https://llm.syncube.tech/v1/containers/{containerId}/activate` (API key auth) This starts the release countdown. After activation, the vault password cannot be retrieved until the release condition is met. ### Step 5: Receive the Release When the release condition is met, your webhook receives: ```json { "event": "container_released", "containerId": "753c784b-ec02-4636-b300-6ad9c84eb38c", "webUrl": "https://www.syncube.tech/public/", "apiUrl": "https://api.syncube.tech/v1/public//access", "releasePassword": "0xacb8a8a80df18db9ad2a4f69832874a09a7772c157c52c6ab5eff09d7f08d03e" } ``` Use the `releasePassword` to derive the KEK, unwrap the DEK, and decrypt the files. ### Key Wrapping Reference (Node.js) ```javascript import { webcrypto } from 'node:crypto'; const { subtle } = webcrypto; // Derive KEK from release password const passwordBytes = Buffer.from(releasePassword.slice(2), 'hex'); const salt = webcrypto.getRandomValues(new Uint8Array(32)); const keyMaterial = await subtle.importKey('raw', passwordBytes, 'PBKDF2', false, ['deriveKey']); const kek = await subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 310000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); // Generate DEK const dek = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); // Wrap DEK with KEK const iv = webcrypto.getRandomValues(new Uint8Array(12)); const rawDek = await subtle.exportKey('raw', dek); const wrappedDek = await subtle.encrypt({ name: 'AES-GCM', iv }, kek, rawDek); // These values go to POST /containers/{id}/keys const releaseWrappedDEK = Buffer.from(wrappedDek).toString('base64'); const releaseKekSalt = Buffer.from(salt).toString('base64'); const releaseWrappedDEKIV = Buffer.from(iv).toString('base64'); ``` ### Key Wrapping Reference (Python) ```python import os, base64, hashlib from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers.aead import AESGCM # Derive KEK from release password password_bytes = bytes.fromhex(release_password[2:]) salt = os.urandom(32) kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=310000) kek_bytes = kdf.derive(password_bytes) # Generate DEK and wrap it dek = AESGCM.generate_key(bit_length=256) iv = os.urandom(12) wrapped_dek = AESGCM(kek_bytes).encrypt(iv, dek, None) # These values go to POST /containers/{id}/keys release_wrapped_dek = base64.b64encode(wrapped_dek).decode() release_kek_salt = base64.b64encode(salt).decode() release_wrapped_dek_iv = base64.b64encode(iv).decode() ``` ## File Encryption & Key Wrapping All uploaded files must be encrypted with AES-256-GCM before upload. Use the `releasePassword` from `POST /containers` to derive a KEK via PBKDF2, generate a random DEK, encrypt files with the DEK, then wrap the DEK with the KEK. Store wrapped keys via `POST /containers/{id}/keys` before activating. Unencrypted files are automatically detected and rejected. Credits are not refunded for rejected uploads. > **PBKDF2 iteration count is part of the contract.** You must use exactly **310,000 iterations** with SHA-256 — that is what release recipients use to derive the KEK when decrypting. A different iteration count produces a different KEK, and recipients cannot recover the DEK no matter how correct the rest of the wrapping is. The salt is per-container and stored as `releaseKekSalt`; the iteration count is fixed and never transmitted. ### Python ```python import os, base64, requests from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers.aead import AESGCM # release_password from POST /containers response password_bytes = bytes.fromhex(release_password[2:]) # Derive KEK from release password salt = os.urandom(32) kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=310000) kek_bytes = kdf.derive(password_bytes) # Generate DEK and encrypt file dek = AESGCM.generate_key(bit_length=256) nonce = os.urandom(12) plaintext = open("file.pdf", "rb").read() ciphertext = AESGCM(dek).encrypt(nonce, plaintext, None) encrypted = nonce + ciphertext # Upload encrypted file to presigned URL requests.put(presigned_url, data=encrypted, headers={"Content-Type": "application/octet-stream"}) # Wrap DEK with KEK (for POST /containers/{id}/keys) wrap_iv = os.urandom(12) wrapped_dek = AESGCM(kek_bytes).encrypt(wrap_iv, dek, None) release_wrapped_dek = base64.b64encode(wrapped_dek).decode() release_kek_salt = base64.b64encode(salt).decode() release_wrapped_dek_iv = base64.b64encode(wrap_iv).decode() # Save wrapped keys import json requests.post( f"{LLM_API_URL}/containers/{container_id}/keys", headers={"X-API-Key": API_KEY, "Content-Type": "application/json"}, data=json.dumps({ "releaseWrappedDEK": release_wrapped_dek, "releaseKekSalt": release_kek_salt, "releaseWrappedDEKIV": release_wrapped_dek_iv, })) ``` ### Node.js ```javascript import { webcrypto } from 'node:crypto'; const { subtle } = webcrypto; // releasePassword from POST /containers response const passwordBytes = Buffer.from(releasePassword.slice(2), 'hex'); // Derive KEK from release password const salt = webcrypto.getRandomValues(new Uint8Array(32)); const keyMaterial = await subtle.importKey( 'raw', passwordBytes, 'PBKDF2', false, ['deriveKey']); const kek = await subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 310000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); // Generate DEK and encrypt file const dek = await subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const iv = webcrypto.getRandomValues(new Uint8Array(12)); const plaintext = await fs.promises.readFile('file.pdf'); const ciphertext = await subtle.encrypt( { name: 'AES-GCM', iv }, dek, plaintext); const encrypted = Buffer.concat([iv, Buffer.from(ciphertext)]); // Upload encrypted file to presigned URL await fetch(presignedUrl, { method: 'PUT', body: encrypted, headers: { 'Content-Type': 'application/octet-stream' }, }); // Wrap DEK with KEK (for POST /containers/{id}/keys) const wrapIv = webcrypto.getRandomValues(new Uint8Array(12)); const rawDek = await subtle.exportKey('raw', dek); const wrappedDek = await subtle.encrypt( { name: 'AES-GCM', iv: wrapIv }, kek, rawDek); const releaseWrappedDEK = Buffer.from(wrappedDek).toString('base64'); const releaseKekSalt = Buffer.from(salt).toString('base64'); const releaseWrappedDEKIV = Buffer.from(wrapIv).toString('base64'); // Save wrapped keys await fetch(`${LLM_API_URL}/containers/${containerId}/keys`, { method: 'POST', headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ releaseWrappedDEK, releaseKekSalt, releaseWrappedDEKIV, }), }); ``` ## Credit System API operations consume credits from your account balance. ### Operation Costs | Operation | Credits | |-----------|---------| | Create container | 10 | | Activate container | 10 (paid containers: dynamic, see below) | | Update container — when `releaseConditions` change | 10 | | Update container — name/description/hidden/background only | free | | Check-in | 1 | | File upload | 1 per MB (rounded up, minimum 1) | | List, get, download, delete, deactivate, balance | free | For paid containers, activation cost is `clamp(ceil(price_usd * 0.05 * 10000), 5_000, 10_000_000)` credits — i.e. the seller pays the 5% platform fee at activation, not at sale. ### Purchasing Credits The LLM API uses a pay-as-you-go model by default — purchase credits and use them, no subscription required. Alternatively, Plus and Pro plan subscribers also receive plan credits each billing cycle (Plus: 100,000 / Pro: 200,000) that can be used alongside purchased credits. - Rate: $1 = 10,000 credits - Packages: 10,000 ($1) | 50,000 ($5) | 100,000 ($10) | 200,000 ($20) | 500,000 ($50) - Payment via cryptocurrency (NOWPayments). Use the `/billing/topup` endpoint. - Deduction order: plan credits first, then purchased credits. ## MCP Server Syncube provides an MCP (Model Context Protocol) server that wraps the LLM API into native tool calls. The MCP server handles encryption locally — plaintext never leaves the user's machine. ### Installation ```bash git clone https://github.com/nicksyncube/mcp-server.git cd mcp-server npm install # or pnpm install npm run build ``` ### Configuration (Claude Desktop) Add to `claude_desktop_config.json`: ```json { "mcpServers": { "syncube": { "command": "node", "args": ["/absolute/path/to/mcp-server/dist/index.js"], "env": { "SYNCUBE_API_KEY": "sk_live_...", "SYNCUBE_API_URL": "https://llm.syncube.tech/v1" } } } } ``` ### Configuration (Claude Code) Add to `.mcp.json`: ```json { "mcpServers": { "syncube": { "command": "node", "args": ["/absolute/path/to/mcp-server/dist/index.js"], "env": { "SYNCUBE_API_KEY": "sk_live_...", "SYNCUBE_API_URL": "https://llm.syncube.tech/v1" } } } } ``` ### Environment Variables | Variable | Required | Default | Description | |----------|----------|---------|-------------| | SYNCUBE_API_KEY | No | — | Your `sk_live_*` API key (use `register` tool to get one) | | SYNCUBE_API_URL | No | https://llm.syncube.tech/v1 | API base URL | ### MCP Tools #### Container Management | Tool | Description | Credits | |------|-------------|---------| | create_container | Create encrypted container with release conditions | 10 | | list_containers | List all containers | Free | | get_container | Get container details | Free | | delete_container | Delete a non-activated container | Free | | activate_container | Activate container (starts countdown) | 10 | | deactivate_container | Deactivate an active container | Free | | checkin | Check in on a periodic container | 1 | #### File Operations | Tool | Description | Credits | |------|-------------|---------| | upload_file | Encrypt a local file and upload to container | 1/MB | | upload_text | Encrypt text content and upload as file | 1/MB | | upload_from_url | Download from URL, encrypt, and upload to container | 1/MB | | list_files | List files in a container | Free | #### Billing | Tool | Description | Credits | |------|-------------|---------| | get_balance | Check credit balance | Free | | topup_credits | Purchase credits via crypto | Free (initiates a paid invoice) | | register | Get a disposable API key (no account needed) | Free (initiates a paid invoice) | | claim | Claim API key after crypto payment | Free | ### Tool Schemas #### create_container Parameters: - name (string, required): Container name, 1-100 characters - description (string, optional): Up to 500 characters - releaseTypes (string[], required): Array of condition types — `["instant"]`, `["time_based"]`, `["periodic_checkin"]`, or `["time_based", "periodic_checkin"]` for multi-condition (OR logic). `"instant"` cannot be combined with others. - releaseTime (number, required if releaseTypes includes "time_based"): Release timestamp in milliseconds - intervalDays (number, required if releaseTypes includes "periodic_checkin"): Check-in interval in days - notifyEmail (string, optional): Email address for release notification - webhookUrl (string, optional): Generic webhook URL (HTTPS only) - slackWebhookUrl (string, optional): Slack Incoming Webhook URL (hooks.slack.com) - teamsWebhookUrl (string, optional): Microsoft Teams webhook URL - discordWebhookUrl (string, optional): Discord webhook URL (discord.com/api/webhooks/) At least one release action (notifyEmail, webhookUrl, slackWebhookUrl, teamsWebhookUrl, or discordWebhookUrl) must be provided. Returns: `{ containerId, creditsDeducted }`. Encryption keys are generated and stored automatically. #### upload_file Parameters: - containerId (string): Container ID - filePath (string): Absolute path to file on user's machine Returns: `{ fileId, fileName, size, encryptedSize, creditsDeducted }` #### upload_text Parameters: - containerId (string): Container ID - fileName (string): File name (e.g. "notes.txt") - content (string): Text content to encrypt Returns: `{ fileId, fileName, size, encryptedSize, creditsDeducted }` #### upload_from_url Parameters: - containerId (string): Container ID - url (string): HTTPS URL to download the file from - fileName (string, optional): File name (extracted from URL if not provided) Returns: `{ fileId, fileName, size, encryptedSize, creditsDeducted }`. Max 50MB. #### activate_container Parameters: - containerId (string): Container ID Returns: `{ success, message, status }` — status reflects the container's state after activation (e.g. `"released"` for instant containers). ### How Encryption Works 1. Container creation: backend creates blockchain vault → generates release password 2. MCP server generates random AES-256-GCM DEK (data encryption key) 3. DEK wrapped with release password via PBKDF2 (310,000 iterations, SHA-256) 4. Wrapped keys stored via API; release password discarded 5. Each file encrypted with DEK (AES-256-GCM, random 12-byte IV per file) 6. On release: vault provides password → recipients unwrap DEK → decrypt files Plaintext, DEK, and release password never leave the user's machine. The server only stores encrypted data and wrapped keys. ### Constraints - Session-bound encryption: DEK is held in memory only. Files can only be uploaded in the same MCP server session that created the container. - Sealed containers: LLM-created containers are sealed — the LLM cannot decrypt them. Only release recipients can decrypt. - Release time: time-based containers must have release date at least 1 hour in the future. ### Example Prompts - "Create a container called 'Family Documents' that releases to alice@example.com on January 1st 2027" - "Upload my will.pdf to the Family Documents container and activate it" - "Create a dead man's switch that releases to bob@example.com if I don't check in every 30 days" - "Create a container that releases on Jan 1 2027 OR if I don't check in for 30 days, notify via Slack" - "Create an instant container that sends a webhook to my server when activated" - "Check my credit balance" - "List all my containers" ## Contact For API support or integration questions: support@syncube.tech