Service Onboarding — Backend Dev Quickstart
Audience: backend devs adding new services to Clawvard's unified service layer.
Goal: get your first service live in < 30 minutes.
Not for you if: you're adding an LLM / STT / TTS / image-gen passthrough — those go through Model Service (
https://token.clawvard.school,sk-xxxkeys), not this layer.Want depth? Read
docs/services-architecture.mdafter this.
TL;DR — the 4-step loop
- Pick a runtime: proxy (stateless 3rd-party API) or job (long async work).
- Append a
ServiceDefinitiontoSERVICESinsrc/lib/services/registry.ts. - (Optional, for typed SDK access) Add I/O interfaces + a
ServiceTypeMapentry inpackages/sdk/src/service-types.ts. - Run
pnpm sdk:gen— the typedcv.<group>.<method>(...)method appears inpackages/sdk/src/generated.tsautomatically.
Then pnpm tsc --noEmit and smoke-test with curl. That's it. No
route edits, no dispatcher edits, no SDK method hand-writing.
Skipping step 3? Your service still works — both via raw HTTP (
POST /api/services/invoke/<group>/<method>) and via the untyped escape hatch (cv.workflow.run<MyOutput>("<group>.<method>", input)). You only need step 3 to get strongly-typedcv.<group>.<method>(...).
Before you start
Two demo services already live in the registry as working templates you can copy from:
| Service | Runtime | Purpose |
|---|---|---|
util.ip-check |
proxy | Calls https://api.ipify.org, returns the egress IP. Free. |
util.delay-echo |
job | Sleeps N seconds, echoes input. Free. |
Open src/lib/services/registry.ts and look at them — they're 30 lines
total and exercise the full pipeline (charge → execute → progress →
result → refund-on-failure).
Verify your local environment by hitting them first:
# Free, no setup needed
curl -X POST http://localhost:3000/api/services/invoke/util/ip-check \
-H "Authorization: Bearer sk-xxx" \
-H "Content-Type: application/json" -d '{}'
# => { "ip": "1.2.3.4" }
# Job lifecycle — start, get jobId, poll
curl -X POST http://localhost:3000/api/services/invoke/util/delay-echo \
-H "Authorization: Bearer sk-xxx" \
-H "Content-Type: application/json" \
-d '{"seconds": 5, "payload": {"hello": "world"}}'
# => { "jobId": "<uuid>", "status": "pending", "pollUrl": "/api/services/jobs/<uuid>" }
curl http://localhost:3000/api/services/jobs/<uuid> \
-H "Authorization: Bearer sk-xxx"
# => { "status": "running" | "completed", "progress": { "pct": 0.5, "note": "tick 2/4" }, ... }
If those work for you, the platform is wired correctly and the rest of this guide is just "make a copy and edit."
Path A — Adding a PROXY service (≈ 30 min)
Proxy services forward a single request to a third-party API
(Shotstack, Mux, Replicate, Runway, ElevenLabs direct, etc.) and pipe
the response back. The dispatcher charges costCredits up-front and
refunds on upstream failure (4xx/5xx or network error).
A.1 Pick a name
| Field | Convention | Example |
|---|---|---|
id |
<group>.<kebab-method>, globally unique |
video.render |
group |
URL segment, also the SDK namespace | video |
method |
camelCase, becomes cv.<group>.<method>() |
render |
A.2 Add the registry entry
// src/lib/services/registry.ts → inside SERVICES[]
{
meta: {
id: "video.render",
group: "video",
method: "render",
summary: "Render a timeline to MP4 via Shotstack.",
costCredits: 50, // user pays 50 cr (≈ ¥5 / $0.50)
approxCostHintUsd: 0.05, // optional: our COGS
access: { beta: true }, // optional gating flags
},
handler: {
kind: "proxy",
upstreamPath: "/render", // joined to base URL below
upstreamBaseUrlEnv: "SHOTSTACK_BASE_URL", // e.g. https://api.shotstack.io/v1
upstreamAuth: {
type: "header", // or "bearer"
headerName: "x-api-key", // ignored when type:"bearer"
envVar: "SHOTSTACK_API_KEY",
},
transformRequest: (input) => ({
timeline: input,
output: { format: "mp4" },
}),
transformResponse: (res) => ({
renderId: (res as { id: string }).id,
}),
},
},
Absolute vs relative
upstreamPath: ifupstreamPathis a full URL (https://...), it's used verbatim andupstreamBaseUrlEnvis ignored. Use absolute when the upstream is fixed; use relative when you want different env values for staging / prod.
A.3 Declare typed I/O (optional but recommended)
In packages/sdk/src/service-types.ts:
// 1. Define the interfaces
export interface VideoRenderInput {
clips: Array<{ asset: { type: string; src: string }; length: number }>;
}
export interface VideoRenderOutput {
renderId: string;
}
// 2. Add an entry to the type map
export interface ServiceTypeMap {
// ...existing entries
"video.render": { input: VideoRenderInput; output: VideoRenderOutput };
}
Then run codegen:
pnpm sdk:gen
# [sdk:gen] wrote 3 services → packages/sdk/src/generated.ts
The new cv.video.render(input) method appears in generated.ts with
proper typing. Do not edit generated.ts by hand — re-run
sdk:gen instead.
A.4 Set env vars
In Vercel (or your local .env.local):
SHOTSTACK_BASE_URL=https://api.shotstack.io/v1
SHOTSTACK_API_KEY=sk_live_...
A.5 Test
curl -X POST http://localhost:3000/api/services/invoke/video/render \
-H "Authorization: Bearer sk-xxx" \
-H "Content-Type: application/json" \
-d '{"clips":[{"asset":{"type":"video","src":"https://..."},"length":10}]}'
Done. Ship.
Path B — Adding a JOB service (2-4 hours)
Job services run > 10 seconds (video transcode, batch processing,
multi-step workflows). The dispatcher inserts a service_jobs row,
charges credits, kicks your execute function in the background, and
returns 202 { jobId, pollUrl } immediately. Failures auto-refund.
B.1 Pre-flight
The service_jobs table is already migrated
(supabase/migrations/20260425000001_service_jobs.sql). Nothing to do.
B.2 Add the registry entry
// src/lib/services/registry.ts → inside SERVICES[]
{
meta: {
id: "video.remove-silence",
group: "video",
method: "removeSilence",
summary: "Auto-detect and cut silent sections out of a video clip.",
costCredits: 20,
access: { beta: true, requiresCoursePurchase: "new-media-editing-101" },
},
validateInput: (input) => {
const o = input as { inputUrl?: unknown };
if (!o?.inputUrl || typeof o.inputUrl !== "string") return "inputUrl is required";
return null;
},
handler: {
kind: "job",
timeoutSec: 300, // soft upper bound
execute: async (input, ctx) => {
const { inputUrl } = input as { inputUrl: string };
await ctx.updateProgress?.(0.1, "downloading");
const localPath = await downloadToScratch(inputUrl);
await ctx.updateProgress?.(0.4, "analyzing");
const windows = detectSilenceWindows(localPath);
await ctx.updateProgress?.(0.85, "rendering");
const outputUrl = await runFfmpegCut(localPath, windows);
// Throw to fail. The dispatcher refunds + records the message.
// Don't catch + return — the auto-refund needs to see the throw.
return {
outputUrl,
cutSeconds: sumWindows(windows),
segmentsRemoved: windows.length,
};
},
},
},
B.3 Progress reporting
Inside execute, call ctx.updateProgress?.(pct, note). Both args are
optional (you can omit progress entirely if your job is opaque). The
SDK surfaces this via .onProgress(cb).
B.4 Failure handling
Throw, don't catch. The dispatcher relies on the exception to:
- Set
status='failed'+ persisterror_messagetoservice_jobs. - Auto-refund
costCreditsviagrantCreditswith type<id>.refund(shows up in user's transaction history as "Refund for failed ...").
If you try/catch and silently return, the user gets charged for a
job that crashed. Don't do that.
B.5 Long-running compute
Vercel Fluid Compute caps at 300 seconds. If your real work takes longer:
| Option | When |
|---|---|
| Vercel Queues (beta) | Any async work > 5 min |
| Modal / RunPod | GPU-bound work (video transcode, SD inference) |
| Shotstack / Mux / Descript API | Don't own the pipeline — use a proxy instead |
Pattern: your execute enqueues the real worker, persists the
external job ID in the result, and returns. Then a separate worker
(your queue consumer) calls back into Supabase to update the
service_jobs row when done.
B.6 SDK method + types
Same pattern as proxy services — add the I/O interfaces + a
ServiceTypeMap entry in packages/sdk/src/service-types.ts, then
pnpm sdk:gen. The codegen knows the runtime is job from the
registry and emits Job<Output> (vs Promise<Output> for proxies)
automatically.
Idempotency
Clients can pass an Idempotency-Key request header to make retries
safe. Same (caller, group, method, key) always maps to the same
internal invocationId, which means:
- Job services: a retry returns the existing
jobIdinstead of creating a second job + double-charging. - Proxy services: the unique constraint in
credit_transactionsswallows the duplicate charge. The upstream call still runs again (idempotency at the upstream's discretion).
# First call: creates a new job, charges 20 cr
curl -X POST .../invoke/video/remove-silence \
-H "Authorization: Bearer sk-xxx" \
-H "Idempotency-Key: client-uuid-abc" \
-d '{"inputUrl": "..."}'
# => { "jobId": "abc...", ... }
# Network blip — client retries with the SAME key
curl -X POST .../invoke/video/remove-silence \
-H "Authorization: Bearer sk-xxx" \
-H "Idempotency-Key: client-uuid-abc" \
-d '{"inputUrl": "..."}'
# => { "jobId": "abc...", ... } ← same jobId, no second charge
// SDK: use the lower-level client form (the typed namespace methods
// don't take per-call options yet — that's a future enhancement)
const job = cv.client.invokeJob<VideoRemoveSilenceOutput>(
"video", "removeSilence",
{ inputUrl: "..." },
{ idempotencyKey: crypto.randomUUID() },
);
const result = await job.wait();
A 409 idempotency_key_collision is returned only when the same key
is reused by a different user — that's a sign of leaked client
state, not a normal retry.
Cancellation
Job services can be cancelled while still pending or running. The
platform marks the row cancelled and refunds the credits charged
on creation.
curl -X DELETE .../jobs/<jobId> \
-H "Authorization: Bearer sk-xxx"
# => { "status": "cancelled", "refunded": true }
// SDK
const job = cv.video.removeSilence({ inputUrl: "..." });
// later — user changes their mind
const { status, refunded } = await job.cancel();
Already-terminal jobs (completed / failed) return their existing
state with refunded: false. Workers that finish AFTER the cancel
land are no-ops — the markJobCompleted / markJobFailed writes
have a WHERE status IN ('pending','running') guard so they can't
overwrite the cancellation.
Pricing
Set costCredits on the meta — that's it. Conversion to ¥/$ is
automatic (10 credits = ¥1, 69 credits = $1).
costCredits |
≈ ¥ | ≈ $ |
|---|---|---|
| 1 | ¥0.10 | $0.0145 |
| 20 | ¥2.00 | $0.29 |
| 50 | ¥5.00 | $0.72 |
| 200 | ¥20.00 | $2.90 |
| 0 | Free | Free |
Pricing rules of thumb:
- Always cover COGS × 2 — the upstream API costs us, support / refunds eat the margin.
- Round to a clean credit number — users see
20 cr, not17 cr. - Free (
costCredits: 0) is fine for smoke tests / loss leaders; the dispatcher just skips the charge/refund dance.
To change a price later, edit the number. Past invocations keep their
historical price (already-charged rows in credit_transactions).
Per-service rate limit
Add a rateLimit block to the meta. The dispatcher enforces per-(user,
service) before charging — a throttled caller gets 429 rate_limit_exceeded with a Reset in <N>s hint. Combine windows as
needed:
meta: {
// ...
rateLimit: { perMinute: 10, perHour: 100, perDay: 500 },
},
All three are optional. Omit the field entirely for no per-service limit (the route layer's global throttle still applies).
Webhooks (job services only)
Pass X-Webhook-Url: https://you.com/cb (or webhookUrl in the SDK)
when invoking a job. When the job reaches a terminal state
(completed / failed / cancelled), the platform POSTs the result
to that URL with HMAC signing:
POST https://you.com/cb
Content-Type: application/json
X-Clawvard-Signature: sha256=<hex>
X-Clawvard-Job-Id: <jobId>
X-Clawvard-Service-Id: video.remove-silence
{"jobId":"…","serviceId":"…","userEmail":"…","status":"completed","payload":{…},"deliveredAt":"…"}
Verify the signature server-side: `sha256(SERVICE_WEBHOOK_SECRET + "."
- raw_body) ===
. Three delivery attempts with 0.5s/1s backoff; fewer than 3× since last success →webhook_status='failed'` in the DB. A receiver that returns 2xx counts as delivered.
Set SERVICE_WEBHOOK_SECRET in your Vercel env to enable. Without it
the dispatcher logs and skips webhook delivery (jobs still run + are
pollable as usual).
Course gating
Wire a service to a paid course by adding access.requiresCoursePurchase:
meta: {
// ...
access: { requiresCoursePurchase: "new-media-editing-101" },
},
Before charging, the dispatcher calls isEnrolled(userEmail, courseId)
against course_enrollments. Not enrolled →
402 course_not_purchased with the course id in the hint field, so
the UI can upsell.
Local testing
Start the dev server
pnpm dev
Mint a test API key
Hit /token-relay in the browser, click "Create API Key", copy the
sk-xxx value. Or use a Supabase session cookie if you're already
logged in.
Cookbook
# 1. List the catalog (public, no auth)
curl http://localhost:3000/api/services/catalog | jq
# 2. Invoke a proxy (synchronous JSON)
curl -X POST http://localhost:3000/api/services/invoke/<group>/<method> \
-H "Authorization: Bearer sk-xxx" \
-H "Content-Type: application/json" \
-d '{...}'
# 3. Invoke a job (returns 202 + jobId)
curl -X POST http://localhost:3000/api/services/invoke/<group>/<method> \
-H "Authorization: Bearer sk-xxx" \
-H "Content-Type: application/json" \
-d '{...}'
# => { "jobId": "...", "pollUrl": "/api/services/jobs/..." }
# 4. Poll
curl http://localhost:3000/api/services/jobs/<id> -H "Authorization: Bearer sk-xxx"
# 5. Your usage history for the service
curl "http://localhost:3000/api/services/usage/<group>/<method>?history=1" \
-H "Authorization: Bearer sk-xxx"
SDK loopback
import { Clawvard } from "@clawvard/sdk";
const cv = new Clawvard({ apiKey: "sk-xxx", baseUrl: "http://localhost:3000" });
// Proxy
const { ip } = await cv.util.ipCheck();
// Job
const result = await cv.util.delayEcho({ seconds: 5, payload: { hi: 1 } })
.onProgress((pct, note) => console.log(pct, note))
.wait();
Common errors & how to read them
| HTTP | error |
Meaning | Fix |
|---|---|---|---|
| 400 | invalid_input |
Your validateInput rejected the body |
Check the hint for the reason |
| 401 | authentication_required |
No session cookie + no Bearer sk-xxx |
Mint a key at /token-relay |
| 402 | insufficient_credits |
Balance < costCredits |
Hint says how many they need |
| 402 | course_not_purchased |
requiresCoursePurchase not satisfied |
Hint = the course id; upsell it |
| 404 | service_not_found |
id not in registry |
Check spelling, restart dev server |
| 500 | upstream_not_configured |
Proxy missing env var | Set upstreamBaseUrlEnv value |
| 500 | upstream_auth_missing |
Proxy missing secret env | Set upstreamAuth.envVar value |
| 500 | charge_failed |
spendCredits RPC errored |
Check Supabase + RLS |
| 502 | upstream_unreachable |
Network error to third party | Check upstream status / DNS |
Job-specific (visible when polling /api/services/jobs/{id}):
status |
Meaning |
|---|---|
pending |
In the queue, not started |
running |
Worker picked it up, executing |
completed |
Success, result is populated |
failed |
Threw — see error.message. Credits were refunded. |
cancelled |
Reserved for future cancel API |
Shipping checklist
-
meta.idis unique and kebab-case -
costCreditschosen (covers COGS × 2 if proxy) - Env vars set in Vercel for both Preview + Production (proxy only)
- (Optional, for typed SDK) I/O types +
ServiceTypeMapentry inpackages/sdk/src/service-types.ts -
pnpm sdk:genran cleanly (regeneratespackages/sdk/src/generated.ts) -
pnpm tsc --noEmitclean - curl test: success path
- curl test: insufficient-credits path (set balance to 0, expect 402)
- (Job) curl test: failure path (force a throw, verify refund in
credit_transactions) - (If gated)
requiresCoursePurchaseset + course actually exists incourse_enrollments - Bumped
packages/sdkversion (only if you changed the public surface)
What this layer does NOT handle
| Concern | Where it lives |
|---|---|
| LLM / STT / TTS / image-gen | Model Service (token.clawvard.school, sk-xxx) |
| Per-token pay-as-you-go | Model Service (cron quota sync) |
| Streaming chat completions | Model Service (native OpenAI SSE) |
| Per-key rate limiting | Model Service's existing throttle |
If your service feels like "send a request to an OpenAI-compatible endpoint and stream tokens back", you don't want this layer — you want to point the OpenAI SDK at Model Service.
Where to read more
docs/services-architecture.md— full reference (architecture diagram, types, credits internals, FAQ)src/lib/services/registry.ts— the source of truth for what's livesrc/lib/services/dispatcher.ts— what runs yourexecutesrc/lib/services/usage.ts— how usage stats are derivedpackages/sdk/src/index.ts— SDK shape
Stuck? Open a thread in #unified-services — paste the id, group,
method, the curl command, and the response body.