REST API

Adama's real-time nature lives on WebSockets, but sometimes you just need plain HTTP. Server-to-server integration, webhooks from Stripe, one-off operations — these don't need a persistent connection. That's what the REST API is for.

Overview

Adama offers two distinct HTTP interfaces:

  1. Platform API: Administrative operations (document creation, space management)
  2. Document Web Endpoints: Custom HTTP handlers you define with @web in your Adama code

Both share common authentication patterns but serve different purposes.

Authentication

Identity Token Header

Most Platform API calls require an Authorization header with your identity token:

Authorization: Bearer your-identity-token-here

Or using the identity query parameter:

GET /api/endpoint?identity=your-identity-token-here

Obtaining an Identity

Identity tokens come from the WebSocket API (see WebSocket API). Once you have one, store it securely and include it in your HTTP requests.

API Keys (Spaces)

For server-to-server integration, you can generate API keys for a space:

# Using the Adama CLI
adama space generate-key --space myspace

This returns a key ID and public key for signing requests.

Platform API Endpoints

Base URL

https://{region}.adama-platform.com

Document Operations

Create a Document

POST /{space}/{key}
Content-Type: application/json
Authorization: Bearer {identity}

{
  "arg": {
    "title": "My Document",
    "owner": "user@example.com"
  }
}

Response:

{
  "result": "created"
}

Direct Message Send

Send a message to a document channel without maintaining a connection:

POST /{space}/{key}/~channel/{channel-name}
Content-Type: application/json
Authorization: Bearer {identity}

{
  "text": "Hello from REST!"
}

Response:

{
  "seq": 42
}

Direct Message Send (Idempotent)

Include a X-Dedupe-Key header for at-most-once delivery:

POST /{space}/{key}/~channel/{channel-name}
Content-Type: application/json
Authorization: Bearer {identity}
X-Dedupe-Key: unique-operation-id-12345

{
  "action": "process"
}

Space Operations

List Spaces

GET /~api/space/list
Authorization: Bearer {identity}

Response:

{
  "spaces": [
    {"space": "myspace", "role": "owner", "created": "2024-01-15"},
    {"space": "other", "role": "developer", "created": "2024-02-20"}
  ]
}

Get Space Schema (Reflect)

GET /~api/space/reflect?space={space}&key={key}
Authorization: Bearer {identity}

Response:

{
  "reflection": {
    "types": { ... },
    "channels": { ... },
    "fields": { ... }
  }
}

Document Listing

GET /~api/document/list?space={space}&limit=100
Authorization: Bearer {identity}

Response (streaming):

{"key": "doc-1", "created": "2024-01-15", "updated": "2024-01-20", "seq": 42}
{"key": "doc-2", "created": "2024-01-16", "updated": "2024-01-18", "seq": 15}

Document Web Endpoints

Every Adama document can expose custom HTTP endpoints via @web handlers. This turns your documents into web servers — great for webhooks, REST APIs, and serving content.

URL Structure

Document web endpoints follow this pattern:

https://{region}.adama-platform.com/{space}/{key}/{path}

Where:

  • {region} is your deployment region
  • {space} is the Adama space name
  • {key} is the document key
  • {path} is handled by your @web handlers

Domain Mapping

You can map custom domains to documents, which simplifies the URLs:

https://api.yourdomain.com/{path}

Maps to a specific space/key combination configured via domain settings.

GET Endpoints

Define GET handlers to serve data:

@web get /api/status {
  return {
    json: { alive: true, version: "1.0" },
    cors: true
  };
}

HTTP Request:

GET /myspace/doc-1/api/status

Response:

{"alive": true, "version": "1.0"}

PUT/POST Endpoints

Handle incoming data with PUT handlers (POST is normalized to PUT):

message WebhookPayload {
  string event;
  string data;
}

@web put /webhook (WebhookPayload payload) {
  // Process the webhook
  return {
    json: { received: true }
  };
}

HTTP Request:

POST /myspace/doc-1/webhook
Content-Type: application/json

{"event": "payment.success", "data": "..."}

Response:

{"received": true}

DELETE Endpoints

@web delete /session {
  // Clear session
  return {
    json: { cleared: true }
  };
}

HTTP Request:

DELETE /myspace/doc-1/session

OPTIONS (CORS Preflight)

Enable browser-based access with OPTIONS handlers:

@web options /api/data {
  return { cors: true };
}

@web get /api/data {
  return {
    json: { items: [1, 2, 3] },
    cors: true
  };
}

Path Parameters

Capture dynamic URL segments:

@web get /user/$id:int {
  return {
    json: { user_id: id }
  };
}

Request: GET /myspace/doc-1/user/42 Response: {"user_id": 42}

Multiple parameters:

@web get /org/$orgId:int/user/$userId:int {
  return {
    json: { org: orgId, user: userId }
  };
}

Query Parameters

Access query strings via the @parameters constant:

@web get /search {
  let query = @parameters.str("q", "");
  let page = @parameters.str("page", "1");
  return {
    json: { query: query, page: page }
  };
}

Request: GET /myspace/doc-1/search?q=hello&page=2 Response: {"query": "hello", "page": "2"}

Request Headers

Access HTTP headers via the @headers constant:

@web get /info {
  let userAgent = @headers["User-Agent"];
  let auth = @headers["Authorization"];
  return {
    json: { user_agent: userAgent }
  };
}

Response Types

JSON

@web get /data {
  return {
    json: { key: "value", count: 42 }
  };
}

Content-Type: application/json

HTML

@web get /page {
  return {
    html: "<html><body><h1>Hello!</h1></body></html>"
  };
}

Content-Type: text/html

XML

Good for webhooks that expect XML responses (Twilio, for instance):

message TwilioSMS {
  string From;
  string Body;
}

@web put /sms (TwilioSMS sms) {
  // Process incoming SMS
  return {
    xml: "<?xml version=\"1.0\"?><Response><Message>Thanks!</Message></Response>"
  };
}

Content-Type: application/xml

CSS and JavaScript

@web get /styles/main {
  return {
    css: "body { font-family: sans-serif; }"
  };
}

@web get /scripts/app {
  return {
    js: "console.log('Hello from Adama!');"
  };
}

Asset Downloads

Serve uploaded files:

private asset user_avatar;

@web get /avatar {
  return {
    asset: user_avatar
  };
}

Redirects

// Temporary redirect (302)
@web get /temp {
  return {
    forward: "https://example.com/new-location"
  };
}

// Permanent redirect (301)
@web get /moved {
  return {
    redirect: "https://example.com/permanent"
  };
}

Signed Identity Response

Issue a signed JWT for the requesting user:

@web get /login/$token:string {
  // Validate the token...
  return {
    sign: "agent"  // Signs the given agent name
  };
}

Response Modifiers

CORS Headers

Enable cross-origin access:

@web get /api {
  return {
    json: { data: "value" },
    cors: true
  };
}

This adds:

  • Access-Control-Allow-Origin: *
  • Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
  • Access-Control-Allow-Headers: Content-Type, Authorization

Caching

Enable HTTP caching:

@web get /static_data {
  return {
    json: { version: "1.0" },
    cache_ttl_seconds: 3600  // Cache for 1 hour
  };
}

This adds Cache-Control: max-age=3600 header.

Webhook Integration Examples

Stripe Webhooks

message StripeEvent {
  string type;
  dynamic data;
}

@web put /webhook/stripe (StripeEvent event) {
  if (event.type == "payment_intent.succeeded") {
    // Handle successful payment
  } else if (event.type == "customer.subscription.deleted") {
    // Handle subscription cancellation
  }
  return {
    json: { received: true }
  };
}

GitHub Webhooks

message GitHubPush {
  string ref;
  dynamic repository;
  dynamic commits;
}

@web put /webhook/github (GitHubPush push) {
  // Verify signature via @headers["X-Hub-Signature-256"]
  let branch = push.ref;
  // Process push event
  return {
    json: { processed: true }
  };
}

Twilio SMS

message TwilioIncoming {
  string From;
  string Body;
  string To;
}

@web put /sms/incoming (TwilioIncoming sms) {
  // Store incoming message
  // sms.From is the sender's phone number
  // sms.Body is the message content

  return {
    xml: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
         "<Response>" +
         "  <Message>Thanks for your message!</Message>" +
         "</Response>"
  };
}

Error Handling

HTTP Status Codes

Adama web handlers return appropriate status codes:

Scenario Status Code
Success 200 OK
Created 200 OK
Redirect (forward) 302 Found
Redirect (redirect) 301 Moved Permanently
Document not found 404 Not Found
No matching handler 404 Not Found
Authorization failed 403 Forbidden
Handler throws 500 Internal Server Error

Error Responses

Return explicit errors:

record User {
  public int id;
  public string name;
}
table<User> _users;

@web get /user/$id:int {
  let found = (iterate _users where id == id);
  if (found.size() == 0) {
    return {
      error: "User not found",
      cors: true
    };
  }
  return {
    json: { user: found[0] },
    cors: true
  };
}

Complete REST API Example

Here's a full notes API to show how it all fits together:

record Note {
  public int id;
  public string title;
  public string content;
  public datetime created_at;
  public datetime updated_at;
}

table<Note> notes;
private int next_id = 1;

// CORS preflight
@web options /api/notes {
  return { cors: true };
}

@web options /api/notes/$id:int {
  return { cors: true };
}

// List all notes
@web get /api/notes {
  return {
    json: { notes: iterate notes order by created_at desc },
    cors: true,
    cache_ttl_seconds: 5
  };
}

// Get single note
@web get /api/notes/$id:int {
  let found = (iterate notes where id == id);
  if (found.size() == 0) {
    return {
      error: "Note not found",
      cors: true
    };
  }
  return {
    json: { note: found[0] },
    cors: true
  };
}

// Create note
message CreateNoteRequest {
  string title;
  string content;
}

@web put /api/notes (CreateNoteRequest req) {
  notes <- {
    id: next_id,
    title: req.title,
    content: req.content,
    created_at: Time.datetime(),
    updated_at: Time.datetime()
  };
  let created_id = next_id;
  next_id++;
  return {
    json: { id: created_id, created: true },
    cors: true
  };
}

// Update note
message UpdateNoteRequest {
  string title;
  string content;
}

@web put /api/notes/$id:int (UpdateNoteRequest req) {
  let found = (iterate notes where id == id);
  if (found.size() == 0) {
    return {
      error: "Note not found",
      cors: true
    };
  }
  (iterate notes where id == id).title = req.title;
  (iterate notes where id == id).content = req.content;
  (iterate notes where id == id).updated_at = Time.datetime();
  return {
    json: { updated: true },
    cors: true
  };
}

// Delete note
@web delete /api/notes/$id:int {
  let count = (iterate notes where id == id).size();
  (iterate notes where id == id).delete();
  return {
    json: { deleted: count > 0 },
    cors: true
  };
}

Usage:

# Create a note
curl -X POST https://region.adama-platform.com/myspace/notes/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "My Note", "content": "Note content here"}'

# List notes
curl https://region.adama-platform.com/myspace/notes/api/notes

# Get single note
curl https://region.adama-platform.com/myspace/notes/api/notes/1

# Update note
curl -X PUT https://region.adama-platform.com/myspace/notes/api/notes/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Title"}'

# Delete note
curl -X DELETE https://region.adama-platform.com/myspace/notes/api/notes/1

Rate Limiting

Adama applies rate limits:

Operation Limit
Document creates 100/minute per space
Direct messages 1000/minute per document
Web requests 1000/minute per document

When rate limited, you get a 429 Too Many Requests response. Plan accordingly.

Practical Advice

Always enable CORS for browser access. Forgetting cors: true is the most common reason your frontend can't reach your endpoint:

@web get /api/data {
  return {
    json: { status: "ok" },
    cors: true  // Don't forget this!
  };
}

Pair OPTIONS with other methods. Browsers send CORS preflight requests. If you don't handle OPTIONS, the actual request never arrives:

message Data {
  string content;
}

// Always define OPTIONS for CORS preflight
@web options /api/resource {
  return { cors: true };
}

@web get /api/resource {
  return { json: { status: "ok" }, cors: true };
}

@web put /api/resource (Data d) {
  return { json: { received: true }, cors: true };
}

Use appropriate response types. JSON for APIs. XML for webhooks that expect it (Twilio, some payment processors). HTML for server-rendered pages. Assets for file downloads.

Validate webhook signatures. Don't trust incoming webhook data blindly:

message Payload {
  dynamic content;
}

procedure validateSignature(maybe<string> s, Payload p) -> bool {
  return true;
}

@web put /webhook (Payload p) {
  let signature = @headers["X-Signature"];
  // Validate signature before processing
  if (!validateSignature(signature, p)) {
    return { error: "Invalid signature" };
  }
  // Process webhook
  return { json: { ok: true } };
}

Cache read-heavy endpoints. If the data doesn't change every request, tell the browser:

@web get /config {
  return {
    json: { version: "1.0" },
    cache_ttl_seconds: 300  // 5 minutes
  };
}

Return structured errors. Give callers something useful to work with:

@web get /resource/$id:int {
  if (id == 0) {
    return {
      json: {
        error: true,
        code: "NOT_FOUND",
        message: "Resource not found"
      },
      cors: true
    };
  }
  return {
    json: { data: id },
    cors: true
  };
}

For real-time needs, pair REST with the WebSocket API or JavaScript Client. REST is for server integrations, webhooks, and one-off operations where holding a WebSocket open doesn't make sense.