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.
https://api.saasignal.saastemly.com. There's no versioning prefix — the API is stable and backwards-compatible.
SaaSignal has two layers:
Bearer <session_token>Bearer sk_live_…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.
Install the SDK
npm install saasignal Create a project and get your API key
Sign in at saasignal.saastemly.com, create an org, create a project, and generate an API key with the scopes you need. Store it in your environment:
SAASIGNAL_KEY=sk_live_your_key_here Initialize the client
import { createClient } from 'saasignal'
export const ss = createClient(process.env.SAASIGNAL_KEY!) Make your first call
Store something in KV from an API route:
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 })
} 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:
Authorization: Bearer sk_live_abc123... API keys are scoped per-project and per-capability. Scopes available:
kv:readkv:writechannels:publishchannels:subscribejobs:writejobs:readPlatform layer — User JWT
Dashboard-type operations (managing orgs, projects, billing) use a session token obtained by signing in:
POST /api/auth/sign-in/email
Content-Type: application/json
{ "email": "[email protected]", "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.
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:
Check remaining balance:
GET /tokens/balance
Authorization: Bearer <session_token> {
"balance": 4821340,
"plan_monthly": 5000000,
"resets_at": "2026-04-01T00:00:00Z"
} 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.
cache: 'no-store' option or rely on the response body returned from PUT.
/kv/:key
Retrieve a value by key. Returns 404 if not found or expired.
curl https://api.saasignal.saastemly.com/kv/session:u_123 \
-H "Authorization: Bearer sk_live_..." {
"key": "session:u_123",
"value": { "userId": "u_123", "role": "admin" },
"expires_at": "2026-03-05T00:00:00Z"
} const entry = await ss.kv.get('session:u_123')
// entry.value, entry.expires_at /kv/:key
Set a value. Optionally set a TTL (seconds) or use if_not_exists for atomic create-or-fail.
value requiredttlif_not_exists409 if key already existscurl -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}' { "key": "session:u_123", "value": { ... }, "expires_at": "2026-03-05T..." } await ss.kv.set('session:u_123', { userId: 'u_123', role: 'admin' }, { ttl: 86400 }) /kv/:key
Delete a key. Returns 204 No Content on success, 404 if not found.
curl -X DELETE https://api.saasignal.saastemly.com/kv/session:u_123 \
-H "Authorization: Bearer sk_live_..." /kv/:key/increment
Atomically increment a numeric value. Creates the key at 0 if it doesn't exist.
delta1, can be negative)ttlcurl -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} // 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 }) /kv
Scan keys by prefix. Returns paginated key names (not values). Costs 5 tokens per call.
prefixlimitcursorcurl "https://api.saasignal.saastemly.com/kv?prefix=session:&limit=50" \
-H "Authorization: Bearer sk_live_..."
# {"keys": ["session:u_1", "session:u_2", ...], "next_cursor": "..."} /kv
Execute up to 100 get/set/delete operations in a single request. Each op is charged individually.
{
"ops": [
{ "op": "get", "key": "config:flags" },
{ "op": "set", "key": "session:new", "value": { "userId": "u_456" }, "ttl": 3600 },
{ "op": "delete", "key": "session:old" }
]
} {
"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
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.
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.
/channels/:channel/publish
Publish an event to all active subscribers of a channel. Returns immediately (delivery is async via the Durable Object fanout).
event required"metric.updated")data requirediduser_idcurl -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}}' { "channel": "org:acme", "event_id": "evt_01JNXS...", "delivered": 3 } await ss.channels.publish('org:acme', {
event: 'metric.updated',
data: { mrr: 42840, delta: +1200 }
}) /channels/:channel/subscribe
Subscribe to a channel. Send Upgrade: websocket to get a WebSocket connection; otherwise you get an SSE stream (text/event-stream).
last_event_idconst ws = new WebSocket(
'wss://api.saasignal.saastemly.com/channels/org:acme/subscribe',
['Bearer', 'sk_live_...']
)
ws.onmessage = (e) => {
const { id, event, data } = JSON.parse(e.data)
console.log(event, data)
} // 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)
}) // 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() e.lastEventId and pass it as ?last_event_id= on reconnect. SaaSignal replays missed messages from the channel history.
/channels/:channel/presence
Get current presence state — who's connected and when they joined.
{
"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" }
]
} /channels/:channel/history
Retrieve recent message history for a channel (last N events, newest first).
limitbeforeJobs
One primitive replaces three services. Queue, Task, and Cron are all the same object — what differs is the trigger.
immediatedelayedscheduledqueuepullhandler URL must respond with 2xx within 30 s. For long work, respond immediately with 202 Accepted and POST results to callback_url when done.
/jobs
Create and enqueue a job. The job is dispatched immediately or according to the trigger.
trigger requiredhandler requiredpayloadmax_attemptsbackoff"linear" or "exponential" (default "exponential")timeoutcallback_urlTrigger shapes
{
"trigger": { "type": "immediate" },
"handler": "https://app.acme.com/api/on-signup",
"payload": { "userId": "u_123" },
"max_attempts": 3,
"backoff": "exponential"
} {
"trigger": {
"type": "delayed",
"delay_seconds": 86400
},
"handler": "https://app.acme.com/api/reminder",
"payload": { "userId": "u_123" }
} {
"trigger": {
"type": "scheduled",
"schedule": "0 9 * * *",
"timezone": "America/New_York"
},
"handler": "https://app.acme.com/api/digest"
} {
"trigger": {
"type": "queue",
"queue_name": "email-pipeline"
},
"handler": "https://app.acme.com/api/send-email",
"payload": { "to": "[email protected]", "template": "welcome" }
} /jobs
List jobs with optional filters. Returns paginated results ordered by creation time descending.
statuspending | running | completed | failedtrigger_typelimitcursor/jobs/:id
Get a single job by ID including full status and run history.
{
"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"
} /jobs/:id
Cancel a pending or scheduled job. Running jobs cannot be cancelled.
/jobs/claim
Claim a pull-type job for processing. Supports work-stealing pools where multiple workers compete for jobs.
{ "queue_name": "mobile-tasks", "limit": 5 } /jobs/:id/complete
Mark a claimed pull job as complete. Required to remove it from the queue.
{ "status": "completed", "result": { "processed": 42 } } Errors
All errors return JSON with a code and message field:
{ "code": "unauthorized", "message": "Invalid or missing API key" } bad_requestunauthorizedforbiddennot_foundconflictif_not_exists conflict; key already setrate_limitedinternal_errorRate Limits
Rate limits are per-project and per-plan. They're enforced at the edge and returned in response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1741218060 Channel subscriptions (SSE/WebSocket connections) do not count against the request rate limit.