Surflet

API Reference

REST API endpoint documentation

All API requests must include the API Key in the Authorization header:

Authorization: Bearer sk_live_...

Base URL: https://api.surflet.app (production)

Endpoint Overview

MethodPathDescription
POST/v1/publishPublish in one step
POST/v1/pagesCreate a draft
GET/v1/pages/:idGet a page
PATCH/v1/pages/:idUpdate a page
POST/v1/pages/:id/publishPublish a draft
POST/v1/pages/:id/revokeRevoke a page
POST/v1/pages/:id/renewRenew an expired page
GET/v1/pages/:id/approvalGet approval status
POST/v1/pages/:id/actions/:actionIdExecute an action
GET/v1/pages/:id/auditGet audit log
GET/v1/inboxList inbox entries
PATCH/v1/inbox/:id/readMark as read
PATCH/v1/inbox/:id/starStar/unstar
DELETE/v1/inbox/:idArchive entry
GET/v1/tenantGet tenant info
GET/v1/tenant/dashboardDashboard metrics
GET/v1/audit/verifyVerify audit chain integrity
POST/v1/workflowsCreate a workflow
GET/v1/workflows/:groupIdGet workflow status
PATCH/v1/pages/:id/blocksAppend blocks (streaming publish)
GET/v1/pages/:id/analyticsGet page analytics
GET/v1/quality-scoreAgent quality score
GET/v1/templatesList templates
POST/v1/publish/template/:namePublish from template
POST/v1/pages/:id/commentsAdd a comment
GET/v1/pages/:id/commentsGet comments
PATCH/v1/pages/:id/comments/:commentIdEdit a comment
DELETE/v1/pages/:id/comments/:commentIdDelete a comment
POST/v1/pages/:id/files/presignRequest presigned upload URL
POST/v1/pages/:id/files/completeCommit a completed upload
GET/v1/pages/:id/filesGet file list
GET/v1/pages/:id/files/:fileId/downloadDownload a file
DELETE/v1/pages/:id/files/:fileIdDelete a file
POST/v1/pages/:id/associationsCreate a page association
GET/v1/pages/:id/eventsSSE real-time event stream
GET/v1/pages/:id/comments/streamSSE comment stream
PATCH/v1/pages/:id/blocks/:blockIdFix an individual block
PUT/v1/pages/:id/blocks/reorderReorder blocks
POST/v1/pages/:id/blocks/insertInsert a block at a position
POST/v1/publish/a2uiA2UI protocol publish
POST/v1/publish/ag-uiAG-UI protocol publish
GET/v1/agent/inboxAgent event inbox
GET/v1/agent/eventsAgent events SSE stream
POST/v1/events/:id/ackAcknowledge an event
GET/v1/agent/inbox/countPending event count
GET/v1/pages/:id/agent-eventsPage event list
POST/v1/auth/oauth/githubInitiate GitHub OAuth
POST/v1/auth/oauth/googleInitiate Google OAuth
GET/v1/auth/meGet current session
POST/v1/auth/logoutLog out
POST/v1/auth/magic-link/sendSend magic link
GET/v1/auth/magic-link/verifyVerify magic link token
POST/v1/auth/agent/registerAgent self-registration
POST/v1/auth/claimClaim agent account
GET/v1/account/api-keysList API keys
POST/v1/account/api-keysCreate API key
DELETE/v1/account/api-keys/:idRevoke API key

POST /v1/publish

Create and publish a page in one step. Equivalent to POST /v1/pages + POST /v1/pages/:id/publish.

The response includes a warnings array (if any blocks have data issues):

{
  "pageId": "page_xxx",
  "pageUrl": "https://...",
  "warnings": [
    "Block 'b1' (callout): use 'content' not 'message'. Fix: rename data.message → data.content",
    "Block 'b2' (metric): data.items is missing. Expected: { items: [{ label, value }] }"
  ]
}

The page still publishes successfully — warnings are advisory. Agents can fix problematic blocks with PATCH /v1/pages/:id/blocks/:blockId.

Request body:

{
  "version": "1.0",
  "page_type": "approval",
  "title": "Refund Approval #4821",
  "summary": "Customer reported product damage, recommend full refund",
  "priority": "high",
  "tags": ["refund", "cs"],
  "blocks": [
    {
      "type": "key_value",
      "data": {
        "title": "Ticket Overview",
        "pairs": [
          {"key": "Customer", "value": "Alice Wang"},
          {"key": "Amount", "value": "$127.50"}
        ]
      }
    }
  ],
  "actions": [
    {
      "action_id": "act_approve",
      "label": "Approve Refund",
      "style": "primary"
    }
  ],
  "access": {
    "mode": "authenticated",
    "allowed_identities": [
      {"type": "email", "value": "[email protected]"}
    ]
  },
  "notify": {
    "channels": [
      {"type": "slack", "target": "#cs-approvals"}
    ]
  },
  "on_action": {
    "default_url": "https://agent.example.com/webhook"
  }
}

Response (201):

{
  "pageId": "pg_abc123",
  "pageUrl": "https://hai.surf/p/pg_abc123",
  "shortUrl": "https://srf.lt/abc123",
  "status": "active",
  "createdAt": "2026-03-23T15:00:00Z",
  "expiresAt": "2026-03-30T15:00:00Z"
}

POST /v1/pages

Create a draft page without publishing immediately.

Request body: Same as /v1/publish.

Response (201):

{
  "pageId": "pg_abc123",
  "status": "draft",
  "createdAt": "2026-03-23T15:00:00Z"
}

GET /v1/pages/:id

Get page details.

Response (200):

{
  "pageId": "pg_abc123",
  "pageUrl": "https://hai.surf/p/pg_abc123",
  "status": "active",
  "page_type": "approval",
  "title": "Refund Approval #4821",
  "summary": "...",
  "priority": "high",
  "tags": ["refund"],
  "created_by": "agent:refund-bot",
  "created_at": "2026-03-23T15:00:00Z",
  "blocks": ["..."],
  "actions": ["..."],
  "access": {"...": "..."},
  "approval_chain": {"...": "..."},
  "stats": {
    "view_count": 3,
    "action_count": 1,
    "last_viewed_at": "2026-03-23T16:00:00Z"
  }
}

PATCH /v1/pages/:id

Update a page. Only pages in draft or active status can be updated.

Request body: Fields to update (partial update).

{
  "title": "Refund Approval #4821 (Updated)",
  "blocks": ["..."]
}

Response (200): The full updated page object.


POST /v1/pages/:id/publish

Publish a draft page.

Request body: Empty, or optional override parameters.

{
  "notify": {
    "channels": [
      {"type": "slack", "target": "#approvals"}
    ]
  }
}

Response (200):

{
  "pageId": "pg_abc123",
  "pageUrl": "https://hai.surf/p/pg_abc123",
  "status": "active",
  "publishedAt": "2026-03-23T15:00:00Z"
}

POST /v1/pages/:id/revoke

Revoke a published page. Once revoked, the page is no longer accessible.

Request body:

{
  "reason": "Duplicate request"
}

Response (200):

{
  "pageId": "pg_abc123",
  "status": "revoked",
  "revokedAt": "2026-03-23T16:00:00Z"
}

POST /v1/pages/:id/renew

Renew an expired page.

Request body:

{
  "expires_at": "2026-05-01T00:00:00Z"
}

Response (200):

{
  "pageId": "pg_abc123",
  "status": "active",
  "expiresAt": "2026-05-01T00:00:00Z"
}

GET /v1/pages/:id/approval

Get approval chain status.

Response (200):

{
  "chain_status": "in_progress",
  "mode": "sequential",
  "current_step": "step_finance",
  "steps": [
    {
      "step_id": "step_manager",
      "name": "Manager Approval",
      "status": "completed",
      "decision": "approved",
      "decided_by": "[email protected]",
      "decided_at": "2026-03-23T10:30:00Z"
    },
    {
      "step_id": "step_finance",
      "name": "Finance Confirmation",
      "status": "pending",
      "assignees": ["[email protected]", "[email protected]"],
      "decisions": []
    }
  ]
}

POST /v1/pages/:id/actions/:actionId

Execute a page action (such as approve or reject).

Request body:

{
  "actor": {
    "identity": "[email protected]",
    "auth_token": "..."
  },
  "comment": "Approved, looks good",
  "form_data": null
}

Response (200):

{
  "action_id": "act_approve",
  "status": "executed",
  "page_state": {
    "previous": "active",
    "current": "completed"
  },
  "executedAt": "2026-03-23T15:30:00Z"
}

GET /v1/pages/:id/audit

Get the audit log for a page.

Query parameters:

ParameterDescriptionDefault
pagePage number1
per_pageItems per page50

Response (200):

{
  "items": [
    {
      "event_id": "aud_001",
      "event_type": "page.created",
      "timestamp": "2026-03-23T15:00:00Z",
      "actor": "agent:refund-bot",
      "details": {"...": "..."},
      "chain_hash": "sha256:abc123..."
    },
    {
      "event_id": "aud_002",
      "event_type": "page.published",
      "timestamp": "2026-03-23T15:00:01Z",
      "actor": "system",
      "chain_hash": "sha256:def456..."
    }
  ],
  "total": 5,
  "page": 1,
  "per_page": 50
}

GET /v1/inbox

List inbox entries for the authenticated user. Requires authentication.

Query parameters:

ParameterDescription
statusFilter by status: unread, read, or archived
priorityFilter by priority level
pageTypeFilter by page type
limitMaximum number of entries to return
offsetPagination offset

Response (200):

{
  "entries": [
    {
      "id": "inbox_abc123",
      "pageId": "pg_abc123",
      "title": "Refund Approval #4821",
      "pageType": "approval",
      "status": "unread",
      "starred": false,
      "priority": "high",
      "createdAt": "2026-03-23T15:00:00Z"
    }
  ],
  "total": 12
}

PATCH /v1/inbox/:id/read

Mark an inbox entry as read. Requires authentication.

curl -X PATCH https://api.surflet.app/v1/inbox/inbox_abc123/read \
  -H "Authorization: Bearer sk_live_..."

Response: 204 No Content.


PATCH /v1/inbox/:id/star

Star or unstar an inbox entry. Requires authentication.

Request body:

{
  "starred": true
}

Omit starred to default to true. Set false to unstar.

Response: 204 No Content.


DELETE /v1/inbox/:id

Archive an inbox entry (soft delete). Requires authentication.

curl -X DELETE https://api.surflet.app/v1/inbox/inbox_abc123 \
  -H "Authorization: Bearer sk_live_..."

Response: 204 No Content.


GET /v1/tenant

Get current tenant information.

Response (200):

{
  "tenant_id": "tn_xxx",
  "name": "My Company",
  "plan": "pro",
  "usage": {
    "pages_this_month": 150,
    "pages_limit": 1000
  }
}

GET /v1/tenant/dashboard

Get dashboard metrics.

Response (200):

{
  "period": "last_30_days",
  "metrics": {
    "total_pages": 150,
    "active_pages": 23,
    "completed_pages": 112,
    "avg_resolution_time_hours": 4.2,
    "approval_rate": 0.89
  }
}

GET /v1/audit/verify

Verify audit chain integrity.

Response (200):

{
  "status": "valid",
  "chain_length": 1523,
  "first_hash": "sha256:000...",
  "last_hash": "sha256:fff...",
  "verified_at": "2026-03-23T16:00:00Z"
}

POST /v1/workflows

Create a workflow (multi-page orchestration).

Request body:

{
  "name": "Refund Processing",
  "pages": [
    {"...": "page 1 payload"},
    {"...": "page 2 payload"}
  ],
  "flow": "sequential"
}

Response (201):

{
  "group_id": "wf_abc123",
  "pages": ["pg_001", "pg_002"],
  "status": "active"
}

GET /v1/workflows/:groupId

Get workflow status.

Response (200):

{
  "group_id": "wf_abc123",
  "name": "Refund Processing",
  "status": "in_progress",
  "pages": [
    {"pageId": "pg_001", "status": "completed"},
    {"pageId": "pg_002", "status": "active"}
  ]
}

PATCH /v1/pages/:id/blocks

Append content blocks to an existing page (streaming / progressive publish). The page status must be active or draft.

Request body:

{
  "blocks": [
    {
      "type": "text",
      "data": {"title": "New Analysis", "content": "## Findings\n\nAfter deeper analysis..."}
    },
    {
      "type": "chart",
      "data": {"title": "Trend", "chart_type": "line", "data": {"labels": ["Mon","Tue"], "datasets": [{"label": "DAU", "data": [1200, 1500]}]}}
    }
  ]
}

Response (200):

{
  "pageId": "pg_abc123",
  "version": 3,
  "totalBlocks": 8,
  "appendedBlocks": 2,
  "appendedBlockIds": ["b_6_a1b2c3d4", "b_7_e5f6g7h8"]
}

GET /v1/pages/:id/analytics

Get page view analytics.

Response (200):

{
  "pageId": "pg_abc123",
  "totalViews": 47,
  "uniqueViewers": 12,
  "dailyViews": {
    "2026-03-20": 15,
    "2026-03-21": 18,
    "2026-03-22": 8
  },
  "createdAt": "2026-03-20T10:00:00Z",
  "status": "active"
}

GET /v1/quality-score

Get the agent quality score for the current tenant.

Response (200):

{
  "tenantId": "tn_xxx",
  "totalPages": 87,
  "totalDecisions": 72,
  "acceptanceRate": 89,
  "avgDecisionTimeMinutes": 35,
  "score": 78,
  "period": "last_100_pages"
}

GET /v1/templates

List all available page templates.

Response (200):

{
  "templates": [
    {
      "name": "refund_approval",
      "description": "Customer refund approval page...",
      "variables": [
        {"key": "customer", "label": "Customer name", "required": true, "type": "string"},
        {"key": "amount", "label": "Refund amount", "required": true, "type": "number"}
      ]
    }
  ]
}

POST /v1/publish/template/:name

Publish a page from a preset template. The template name is a path parameter; variables are passed in the request body.

Request body:

{
  "variables": {
    "customer": "Alice Wang",
    "order_id": "#ORD-7891",
    "amount": 127.50,
    "reason": "Product arrived damaged"
  }
}

Response (201): Same as POST /v1/publish.

Error (404): Returns the list of available templates when the template does not exist.


POST /v1/pages/:id/comments

Add a comment to a page. Supports nested thread replies.

Request body:

{
  "content": "The refund amount needs to be verified.",
  "author_name": "Manager Zhang",
  "author_type": "user",
  "block_id": "blk_details",
  "parent_id": "cmt_abc123"
}
FieldTypeDescription
contentstringComment content (required)
author_namestringAuthor display name (optional)
author_typestringAuthor type: user / agent (optional, defaults to user)
block_idstringAssociated Block ID (optional, for anchoring the comment)
parent_idstringParent comment ID (optional, for nested replies)

Response (201): The created comment object.


GET /v1/pages/:id/comments

Get all comments for a page. No authentication required (accessible on public pages).

Response (200):

{
  "comments": [
    {
      "id": "cmt_abc123",
      "pageId": "pg_abc123",
      "content": "The refund amount needs to be verified.",
      "authorName": "Manager Zhang",
      "authorType": "user",
      "createdAt": "2026-03-23T15:00:00Z"
    }
  ]
}

PATCH /v1/pages/:id/comments/:commentId

Edit comment content.

Request body:

{
  "content": "Amount confirmed as correct.",
  "editor_identity": "[email protected]"
}

Response (200): The updated comment object.


DELETE /v1/pages/:id/comments/:commentId

Delete a comment.

Request body (optional):

{
  "deleter_identity": "[email protected]"
}

Response: 204 No Content.


POST /v1/pages/:id/files/presign

Request a presigned upload URL. Authentication is optional — public pages allow anonymous uploads.

Request body:

{
  "filename": "invoice-4821.pdf",
  "mime_type": "application/pdf",
  "size_bytes": 524288,
  "uploaded_by": "agent:refund-bot",
  "upload_type": "agent"
}

Response (200):

{
  "uploadUrl": "/v1/storage/upload/tenant%2Fpg_abc123%2F1713000000000_invoice-4821.pdf",
  "storageKey": "tenant/pg_abc123/1713000000000_invoice-4821.pdf",
  "filename": "invoice-4821.pdf",
  "mimeType": "application/pdf",
  "uploadedBy": "agent:refund-bot",
  "uploadType": "agent"
}

After receiving the uploadUrl, PUT the file bytes directly to it (no JSON, raw body). Then call /complete to commit the record.


POST /v1/pages/:id/files/complete

Commit an upload after the file bytes have been PUT to the uploadUrl. Registers the file record and publishes a file.uploaded SSE event.

Request body:

{
  "storage_key": "tenant/pg_abc123/1713000000000_invoice-4821.pdf",
  "filename": "invoice-4821.pdf",
  "mime_type": "application/pdf",
  "uploaded_by": "agent:refund-bot",
  "upload_type": "agent"
}

Response (201):

{
  "id": "file_abc123",
  "pageId": "pg_abc123",
  "filename": "invoice-4821.pdf",
  "mimeType": "application/pdf",
  "sizeBytes": 524288,
  "storageKey": "tenant/pg_abc123/1713000000000_invoice-4821.pdf",
  "uploadedBy": "agent:refund-bot",
  "createdAt": "2026-03-23T15:00:00Z"
}

See File Uploads guide for the complete three-step flow.


GET /v1/pages/:id/files

Get all file attachments for a page. No authentication required.

Response (200):

{
  "files": [
    {
      "id": "file_abc123",
      "filename": "invoice-4821.pdf",
      "mimeType": "application/pdf",
      "sizeBytes": 524288,
      "uploadedBy": "agent:refund-bot",
      "createdAt": "2026-03-23T15:00:00Z"
    }
  ]
}

GET /v1/pages/:id/files/:fileId/download

Download a file. Redirects to the presigned download URL which serves the raw file bytes.

Use -L in curl or follow redirects in your HTTP client.

Error (404): File not found or does not belong to the specified page.


DELETE /v1/pages/:id/files/:fileId

Delete a file. Removes the database record and the file from disk. Publishes a file.deleted SSE event.

Response: 204 No Content.


POST /v1/pages/:id/associations

Create a typed association between two pages. Requires authentication.

Request body:

{
  "targetPageId": "pg_xyz789",
  "relationType": "follow_up"
}
FieldTypeDescription
targetPageIdstringThe page to associate with
relationTypestringAssociation type (e.g. follow_up, duplicate, related)

Response (201):

{
  "id": "assoc_abc123",
  "sourcePageId": "pg_abc123",
  "targetPageId": "pg_xyz789",
  "relationType": "follow_up",
  "tenantId": "tenant_xxx",
  "createdAt": "2026-04-15T10:00:00Z"
}

GET /v1/pages/:id/events

SSE (Server-Sent Events) real-time event stream. Long-lived connection for receiving real-time page updates.

Response headers:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Event format:

data: {"type":"connected","pageId":"pg_abc123"}

data: {"type":"blocks.appended","data":{"version":3,"newBlocks":[...]}}

data: {"type":"action.executed","data":{"actionId":"act_approve","actor":"..."}}

data: {"type":"comment.added","data":{"commentId":"cmt_abc","content":"..."}}

data: {"type":"file.uploaded","data":{"fileId":"file_abc","filename":"..."}}

The server sends a keepalive comment every 30 seconds to maintain the connection.


GET /v1/pages/:id/comments/stream

SSE stream for comment events on a specific page. No authentication required (works on public pages).

Response headers:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

The server sends an initial : heartbeat line immediately after connection. Subsequent messages are comment-related page events from the same Redis channel as /events.

curl -N https://api.surflet.app/v1/pages/pg_abc123/comments/stream

Real-Time Streaming (SSE)

Surflet exposes three SSE streams for different audiences:

EndpointAuthScopePurpose
GET /v1/pages/:id/eventsNoPer pageAll page events (blocks, actions, comments, files)
GET /v1/pages/:id/comments/streamNoPer pageComment events (subset of page events)
GET /v1/agent/eventsYesPer tenantAgent-targeted events across all pages

Connection pattern

const es = new EventSource(
  "https://api.surflet.app/v1/pages/pg_abc123/events"
);

es.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  if (payload.type === "comment.added") {
    console.log("New comment:", payload.data);
  }
};

Event types on page streams

typeDescription
connectedSent on connection, includes pageId
blocks.appendedNew blocks added to the page
action.executedA page action was executed
comment.addedNew comment posted
file.uploadedFile successfully committed
file.deletedFile removed

Reconnection with Last-Event-ID

The agent SSE stream (/v1/agent/events) supports the standard Last-Event-ID header for gap-filling on reconnect. Missed events since the last received ID are replayed before resuming the live stream.

curl -N https://api.surflet.app/v1/agent/events \
  -H "Authorization: Bearer sk_live_..." \
  -H "Last-Event-ID: evt_abc123"

PATCH /v1/pages/:id/blocks/:blockId

Incrementally fix the data of an individual content block. Agents can use this endpoint to fix issues after receiving publish warnings, without republishing the entire page.

Request body:

{
  "data": { "content": "Corrected content" }
}

You can also simultaneously update type, title, group, and tab.

Response:

{
  "pageId": "page_xxx",
  "blockId": "b1",
  "version": 3,
  "block": { "id": "b1", "type": "callout", "data": { "content": "Corrected content" } },
  "warnings": []
}

PUT /v1/pages/:id/blocks/reorder

Reorder blocks within a page.

Request body:

{ "block_order": ["block_id_3", "block_id_1", "block_id_2"] }

Blocks not listed are automatically appended to the end.


POST /v1/pages/:id/blocks/insert

Insert a new block at a specific position.

Request body:

{ "position": 2, "block": { "type": "callout", "data": { "content": "Inserted!", "style": "info" } } }

POST /v1/publish/a2ui

Accepts an A2UI v0.9 component list, automatically translates it to Surflet blocks, and publishes a page.

Request body (A2UI envelope):

{
  "version": "v0.9",
  "title": "User Profile",
  "updateComponents": {
    "surfaceId": "demo",
    "components": [
      { "id": "name", "component": "Text", "text": "Alice Wang", "variant": "h1" },
      { "id": "row", "component": "Row", "children": ["col1", "col2"] },
      { "id": "col1", "component": "Column", "children": ["info"], "weight": 2 },
      { "id": "col2", "component": "Column", "children": ["avatar"], "weight": 1 },
      { "id": "info", "component": "Text", "text": "Engineer @ Acme" },
      { "id": "avatar", "component": "Image", "src": "https://..." }
    ]
  }
}

Also supports passing a component array directly (without the envelope). Row/Column are automatically converted to Surflet layout columns.


POST /v1/publish/ag-ui

Accepts an AG-UI event array, automatically translates it to Surflet blocks, and publishes a page.

Request body:

{
  "title": "Agent Analysis",
  "events": [
    { "type": "RUN_STARTED", "threadId": "t1", "runId": "r1" },
    { "type": "STEP_STARTED", "stepName": "research" },
    { "type": "TOOL_CALL_START", "toolCallId": "tc1", "toolCallName": "search_db" },
    { "type": "TOOL_CALL_ARGS", "toolCallId": "tc1", "delta": "{\"query\": \"refunds\"}" },
    { "type": "TOOL_CALL_END", "toolCallId": "tc1" },
    { "type": "TOOL_CALL_RESULT", "toolCallId": "tc1", "content": "Found 5 records" },
    { "type": "TEXT_MESSAGE_START", "messageId": "m1", "role": "assistant" },
    { "type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "## Summary\n\n..." },
    { "type": "TEXT_MESSAGE_END", "messageId": "m1" },
    { "type": "RUN_FINISHED", "threadId": "t1", "runId": "r1" }
  ]
}

Event translation rules: text messages → text block, tool calls → code block, results → callout, errors → error callout, steps → stepper, reasoning → collapsible text block.


Authentication

POST /v1/auth/oauth/github

Initiate GitHub OAuth sign-in. Returns a redirect URL.

Response (200):

{ "url": "https://github.com/login/oauth/authorize?..." }

POST /v1/auth/oauth/google

Initiate Google OAuth sign-in. Returns a redirect URL.

Response (200):

{ "url": "https://accounts.google.com/o/oauth2/v2/auth?..." }

GET /v1/auth/me

Get the currently authenticated user or agent.

Response (200):

{
  "id": "user_xxx",
  "email": "[email protected]",
  "tenantId": "tenant_xxx",
  "type": "human"
}

POST /v1/auth/logout

Invalidate the current session token.

Response: 204 No Content.


POST /v1/auth/magic-link/send

Send a passwordless magic link to a page viewer.

Request body:

{
  "email": "[email protected]",
  "pageId": "page_xxx",
  "redirectUrl": "https://hai.surf/p/page_xxx"
}

Response (200):

{ "sent": true }

GET /v1/auth/magic-link/verify

Verify a magic link token (called automatically when the user clicks the link).

Query parameters:

ParameterDescription
tokenThe magic link token from the email

Response: Redirects to redirectUrl with a session token.


POST /v1/auth/agent/register

Create a new Surflet account for an agent without human involvement.

Request body:

{
  "name": "my-coding-agent",
  "owner_email": "[email protected]"
}

Response (201):

{
  "apiKey": "sk_live_...",
  "tenantId": "tenant_xxx",
  "claimCode": "SURF-7K3M",
  "claimUrl": "https://surflet.app/claim",
  "message": "Account created. To manage: visit surflet.app/claim and enter code SURF-7K3M"
}

POST /v1/auth/claim

Claim an agent-created account using a claim code.

Request body:

{
  "claimCode": "SURF-7K3M"
}

Response (200):

{
  "tenantId": "tenant_xxx",
  "claimed": true
}

Account

GET /v1/account/api-keys

List all API keys for the current tenant.

Response (200):

{
  "keys": [
    {
      "id": "key_xxx",
      "name": "Production Key",
      "prefix": "sk_live_abc...",
      "createdAt": "2026-03-23T15:00:00Z",
      "lastUsedAt": "2026-04-10T09:00:00Z"
    }
  ]
}

POST /v1/account/api-keys

Generate a new API key.

Request body:

{
  "name": "Production Key"
}

Response (201):

{
  "id": "key_xxx",
  "name": "Production Key",
  "key": "sk_live_...",
  "createdAt": "2026-03-23T15:00:00Z"
}

The full key value is only returned once — save it immediately.


DELETE /v1/account/api-keys/:id

Revoke an API key. All requests using this key will be rejected immediately.

Response: 204 No Content.


Error Responses

All errors use a consistent format:

{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "Field 'title' is required",
    "details": {
      "field": "title",
      "constraint": "required"
    }
  }
}
HTTP StatusError CodeDescription
400INVALID_REQUESTInvalid request parameters
401UNAUTHORIZEDAPI Key is invalid or missing
403FORBIDDENInsufficient permissions
404NOT_FOUNDResource does not exist
409CONFLICTState conflict (e.g., duplicate action)
429RATE_LIMITEDToo many requests
500INTERNAL_ERRORInternal server error

Rate Limits

PlanLimit
Free60 requests/minute
Pro600 requests/minute
EnterpriseCustom

When the limit is exceeded, a 429 status code is returned. The Retry-After header indicates how many seconds to wait.

On this page