Search docs ⌘K

Introduction

SaaSignal gives your Vercel app a backend in three API calls. No infrastructure to manage, no new services to learn — just KV, Channels, and Jobs over HTTPS.

Base URL: All API requests go to https://api.saasignal.saastemly.com. There's no versioning prefix — the API is stable and backwards-compatible.

SaaSignal has two layers:

Layer
Auth
Purpose
Platform
Bearer <session_token>
Account, orgs, billing, API key management
Core
Bearer sk_live_…
KV, Channels, Jobs — your production workloads

As a developer integrating SaaSignal into your app, you'll spend most of your time with the Core layer. The Platform layer is for your dashboard and management tooling.

Quick Start

From zero to a working integration in under 5 minutes with Next.js.

1

Install the SDK

terminal
npm install saasignal
2

Create a project and get your API key

Sign in at saasignal.io, create an org, create a project, and generate an API key with the scopes you need. Store it in your environment:

.env.local
SAASIGNAL_KEY=sk_live_your_key_here
3

Initialize the client

lib/saasignal.ts
import { createClient } from 'saasignal'

export const ss = createClient(process.env.SAASIGNAL_KEY!)
4

Make your first call

Store something in KV from an API route:

app/api/session/route.ts
import { ss } from '@/lib/saasignal'
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const session = await req.json()

  await ss.kv.set(`session:$${session.userId}`, session, { ttl: 86400 })

  return NextResponse.json({ ok: true })
}
Direct HTTP: The SDK is a thin wrapper. Every example in this doc also shows raw curl — if you prefer direct HTTP calls or use a different language, you're covered.

Authentication

Core layer — Project API keys

All KV, Channels, and Jobs endpoints require a project API key passed as a Bearer token:

http
Authorization: Bearer sk_live_abc123...

API keys are scoped per-project and per-capability. Scopes available:

Scope
Grants access to
kv:read
GET /kv/:key, GET /kv (scan)
kv:write
PUT, DELETE, POST /kv/:key/increment, POST /kv (batch)
channels:publish
POST /channels/:channel/publish and batch publish
channels:subscribe
GET subscribe, presence, history
jobs:write
POST /jobs, DELETE /jobs/:id
jobs:read
GET /jobs, GET /jobs/:id, GET /jobs/:id/runs

Platform layer — User JWT

Dashboard-type operations (managing orgs, projects, billing) use a session token obtained by signing in:

http
POST /api/auth/sign-in/email
Content-Type: application/json

{ "email": "you@acme.com", "password": "..." }

The response includes a token field. Use it as Authorization: Bearer <token> for Platform routes. Better Auth also sets a session cookie automatically for browser clients.

Keep API keys server-side. Never expose sk_live_ keys in client-side JavaScript. Use the SDK's browser-safe subscribe API for client subscriptions (it proxies through your own backend).

Token Model

Core operations consume tokens from your project's monthly balance. The cost per operation is deterministic and returned in X-Tokens-Used:

KV read
1 token
GET /kv/:key
KV write
2 tokens
PUT, DELETE, increment
KV scan
5 tokens
GET /kv (list)
Channel publish
2 tokens
per message/channel
Job dispatch
5 tokens
POST /jobs
Job run
10 tokens
per execution attempt

Check remaining balance:

http
GET /tokens/balance
Authorization: Bearer <session_token>
json — response
{
  "balance": 4821340,
  "plan_monthly": 5000000,
  "resets_at": "2026-04-01T00:00:00Z"
}
KV

KV Store

A global key-value store backed by Cloudflare KV. Sub-millisecond reads from 300+ edge locations. Keys are namespaced per project — no collisions between projects.

Eventual consistency: CF KV propagates writes globally within ~60 seconds. For strong read-after-write within the same edge PoP (e.g., right after setting a session), use the cache: 'no-store' option or rely on the response body returned from PUT.
GET

/kv/:key

Retrieve a value by key. Returns 404 if not found or expired.

curl
curl https://api.saasignal.saastemly.com/kv/session:u_123 \
  -H "Authorization: Bearer sk_live_..."
json — 200 OK
{
  "key": "session:u_123",
  "value": { "userId": "u_123", "role": "admin" },
  "expires_at": "2026-03-05T00:00:00Z"
}
typescript
const entry = await ss.kv.get('session:u_123')
// entry.value, entry.expires_at
PUT

/kv/:key

Set a value. Optionally set a TTL (seconds) or use if_not_exists for atomic create-or-fail.

Body field
Type
Description
value required
any JSON
Value to store
ttl
integer (s)
Expiration in seconds from now
if_not_exists
boolean
Fail with 409 if key already exists
curl
curl -X PUT https://api.saasignal.saastemly.com/kv/session:u_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"value": {"userId": "u_123", "role": "admin"}, "ttl": 86400}'
json — 200 OK
{ "key": "session:u_123", "value": { ... }, "expires_at": "2026-03-05T..." }
typescript
await ss.kv.set('session:u_123', { userId: 'u_123', role: 'admin' }, { ttl: 86400 })
DELETE

/kv/:key

Delete a key. Returns 204 No Content on success, 404 if not found.

curl
curl -X DELETE https://api.saasignal.saastemly.com/kv/session:u_123 \
  -H "Authorization: Bearer sk_live_..."
POST

/kv/:key/increment

Atomically increment a numeric value. Creates the key at 0 if it doesn't exist.

Body field
Type
Description
delta
number
Amount to add (default 1, can be negative)
ttl
integer (s)
Reset expiration on each increment
curl
curl -X POST https://api.saasignal.saastemly.com/kv/ratelimit:u_123/increment \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"delta": 1, "ttl": 60}'
# {"key": "ratelimit:u_123", "value": 7}
typescript
// Rate limit: max 100 req/min
const { value } = await ss.kv.increment(`ratelimit:$${userId}`, { ttl: 60 })
if (value > 100) return new Response('Rate limited', { status: 429 })
GET

/kv

Scan keys by prefix. Returns paginated key names (not values). Costs 5 tokens per call.

Query param
Type
Description
prefix
string
Filter keys by prefix
limit
integer
Max results (default 100, max 1000)
cursor
string
Pagination cursor from previous response
curl
curl "https://api.saasignal.saastemly.com/kv?prefix=session:&limit=50" \
  -H "Authorization: Bearer sk_live_..."
# {"keys": ["session:u_1", "session:u_2", ...], "next_cursor": "..."}
POST

/kv

Execute up to 100 get/set/delete operations in a single request. Each op is charged individually.

json — body
{
  "ops": [
    { "op": "get", "key": "config:flags" },
    { "op": "set", "key": "session:new", "value": { "userId": "u_456" }, "ttl": 3600 },
    { "op": "delete", "key": "session:old" }
  ]
}
json — 200 OK
{
  "results": [
    { "key": "config:flags", "value": { "dark_mode": true }, "status": "ok" },
    { "key": "session:new", "value": { "userId": "u_456" }, "status": "ok" },
    { "key": "session:old", "value": null, "status": "ok" }
  ]
}
Channels

Channels

Real-time pub/sub backed by Cloudflare Durable Objects. Each channel is a persistent WebSocket hub — publish from your API route, subscribe from the browser via WebSocket or SSE. Channels maintain presence state and message history automatically.

Architecture: Each unique channel name maps to a dedicated Durable Object. The DO holds connections, fans out messages, tracks presence, and keeps a sliding history window. There's no separate "create channel" step — channels are created on first access.
POST

/channels/:channel/publish

Publish an event to all active subscribers of a channel. Returns immediately (delivery is async via the Durable Object fanout).

Body field
Type
Description
event required
string
Event name (e.g. "metric.updated")
data required
any JSON
Event payload
id
string
Custom event ID (auto-generated if omitted)
user_id
string
Associate event with a user for presence
curl
curl -X POST https://api.saasignal.saastemly.com/channels/org:acme/publish \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"event": "metric.updated", "data": {"mrr": 42840}}'
json — 202 Accepted
{ "channel": "org:acme", "event_id": "evt_01JNXS...", "delivered": 3 }
typescript
await ss.channels.publish('org:acme', {
  event: 'metric.updated',
  data: { mrr: 42840, delta: +1200 }
})
GET

/channels/:channel/subscribe

Subscribe to a channel. Send Upgrade: websocket to get a WebSocket connection; otherwise you get an SSE stream (text/event-stream).

Query param
Type
Description
last_event_id
string
Resume from this event ID (replays missed messages)
javascript
const ws = new WebSocket(
  'wss://api.saasignal.io/channels/org:acme/subscribe',
  ['Bearer', 'sk_live_...']
)

ws.onmessage = (e) => {
  const { id, event, data } = JSON.parse(e.data)
  console.log(event, data)
}
javascript
// SSE — proxy through your own API route for auth
const es = new EventSource('/api/subscribe?channel=org:acme')
es.addEventListener('metric.updated', (e) => {
  const data = JSON.parse(e.data)
  setMRR(data.mrr)
})
typescript
// Browser SDK handles auth via your /api/saasignal proxy
const channel = ss.channels.subscribe('org:acme')

channel.on('metric.updated', (data) => setMRR(data.mrr))
channel.on('user.joined', (data) => addUser(data))

// Cleanup
return () => channel.close()
Resume on reconnect: Store the last event ID from e.lastEventId and pass it as ?last_event_id= on reconnect. SaaSignal replays missed messages from the channel history.
GET

/channels/:channel/presence

Get current presence state — who's connected and when they joined.

json — 200 OK
{
  "channel": "org:acme",
  "count": 3,
  "users": [
    { "user_id": "u_123", "joined_at": "2026-03-04T12:00:00Z" },
    { "user_id": "u_456", "joined_at": "2026-03-04T12:01:30Z" }
  ]
}
GET

/channels/:channel/history

Retrieve recent message history for a channel (last N events, newest first).

Query param
Type
Description
limit
integer
Max messages (default 50, max 1000)
before
string
Return messages before this event ID
Jobs

Jobs

One primitive replaces three services. Queue, Task, and Cron are all the same object — what differs is the trigger.

trigger.type
Concept
Example use case
immediate
One-off task
Send email on signup, call webhook
delayed
Delayed task
Send reminder 24h from now
scheduled
Cron
Nightly billing digest, cache warm-up
queue
Push queue
Fan-out pipeline, rate-limited workers
pull
Pull queue
Work-stealing pool, mobile job claiming
30-second handler constraint: SaaSignal runs on Cloudflare Workers, which impose a 30 s limit on outbound subrequests. Your handler URL must respond with 2xx within 30 s. For long work, respond immediately with 202 Accepted and POST results to callback_url when done.
POST

/jobs

Create and enqueue a job. The job is dispatched immediately or according to the trigger.

Field
Type
Description
trigger required
object
Trigger config (see below)
handler required
string (URL)
HTTPS endpoint to POST payload to
payload
any JSON
Data passed to the handler
max_attempts
integer
Max retry attempts (default 3)
backoff
string
"linear" or "exponential" (default "exponential")
timeout
integer (s)
Handler timeout, max 30 (default 30)
callback_url
string (URL)
POST job result here when done

Trigger shapes

json
{
  "trigger": { "type": "immediate" },
  "handler": "https://app.acme.com/api/on-signup",
  "payload": { "userId": "u_123" },
  "max_attempts": 3,
  "backoff": "exponential"
}
json
{
  "trigger": {
    "type": "delayed",
    "delay_seconds": 86400
  },
  "handler": "https://app.acme.com/api/reminder",
  "payload": { "userId": "u_123" }
}
json
{
  "trigger": {
    "type": "scheduled",
    "schedule": "0 9 * * *",
    "timezone": "America/New_York"
  },
  "handler": "https://app.acme.com/api/digest"
}
json
{
  "trigger": {
    "type": "queue",
    "queue_name": "email-pipeline"
  },
  "handler": "https://app.acme.com/api/send-email",
  "payload": { "to": "user@acme.com", "template": "welcome" }
}
GET

/jobs

List jobs with optional filters. Returns paginated results ordered by creation time descending.

Query param
Type
Description
status
string
pending | running | completed | failed
trigger_type
string
Filter by trigger type
limit
integer
Max results (default 50, max 200)
cursor
string
Pagination cursor
GET

/jobs/:id

Get a single job by ID including full status and run history.

json — 200 OK
{
  "id": "job_01JNXS...",
  "status": "completed",
  "trigger": { "type": "immediate" },
  "handler": "https://app.acme.com/api/on-signup",
  "attempts": 1,
  "max_attempts": 3,
  "created_at": "2026-03-04T12:00:00Z",
  "completed_at": "2026-03-04T12:00:01Z"
}
DELETE

/jobs/:id

Cancel a pending or scheduled job. Running jobs cannot be cancelled.

POST

/jobs/claim

Claim a pull-type job for processing. Supports work-stealing pools where multiple workers compete for jobs.

json — body
{ "queue_name": "mobile-tasks", "limit": 5 }
POST

/jobs/:id/complete

Mark a claimed pull job as complete. Required to remove it from the queue.

json — body
{ "status": "completed", "result": { "processed": 42 } }

Errors

All errors return JSON with a code and message field:

json
{ "code": "unauthorized", "message": "Invalid or missing API key" }
HTTP status
code
When it happens
400
bad_request
Invalid request body or missing required fields
401
unauthorized
Missing or invalid API key / session token
403
forbidden
API key lacks the required scope
404
not_found
Resource doesn't exist
409
conflict
KV if_not_exists conflict; key already set
429
rate_limited
Request rate limit exceeded
500
internal_error
Unexpected server error

Rate Limits

Rate limits are per-project and per-plan. They're enforced at the edge and returned in response headers:

http response headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1741218060
Plan
Requests/s
Burst
Free
10 req/s
50
Pro
100 req/s
500
Scale
1,000 req/s
5,000
Enterprise
custom
custom

Channel subscriptions (SSE/WebSocket connections) do not count against the request rate limit.