Scrawn LogoScrawn Docs
Scrawn

API Reference

Complete reference for Scrawn SDK and gRPC API

SDK API

scrawn()

import { scrawn } from "@scrawn/core";

const biller = scrawn(config: ScrawnInitConfig): Scrawn

Creates a billing instance.

Parameters:

ParameterTypeDefaultDescription
apiKeystringYour Scrawn API key (starts with scrn_)
baseURLstringBackend server URL
securebooleantrueWhether to use TLS for gRPC. Set to false for local dev
tagsreadonly string[]Known tag names for compile-time type checking on biller.tag()
expressionsreadonly string[]Known expression names for compile-time type checking on biller.expr()
retryCountnumber2Auto-retry attempts on transient network errors

Example:

const biller = scrawn({
  apiKey: process.env.SCRAWN_KEY as `scrn_${string}`,
  baseURL: process.env.SCRAWN_BASE_URL || "http://localhost:8069",
  tags: ["PREMIUM_CALL", "EXTRA_FEE"] as const,
});

basicUsageEventConsumer

biller.basicUsageEventConsumer(payload: EventPayload, options?: { onError? }): Promise<void>

Track a billable usage event.

Parameters:

ParameterTypeRequiredDescription
userIdstringYesUnique user identifier
debitnumber | PriceExprYesCents or pricing expression (tag(), mul(), etc.)
metadataobjectNoArbitrary metadata

Returns: Promise<void>

Example:

// Direct amount — 500 cents = $5.00
await biller.basicUsageEventConsumer({
  userId: "user-123",
  debit: 500,
});

// Price tag
await biller.basicUsageEventConsumer({
  userId: "user-123",
  debit: tag("PREMIUM_CALL"),
});

// Pricing expression
await biller.basicUsageEventConsumer({
  userId: "user-123",
  debit: mul(tag("PER_CALL"), 3),
});

Error callback:

await biller.basicUsageEventConsumer(
  { userId: "user-123", debit: 500 },
  {
    onError: (error, context) => {
      if (error.retryable && context) await context.retry();
    },
  }
);

middlewareEventConsumer

biller.middlewareEventConsumer(config: MiddlewareEventConfig): 
  (req, res, next) => Promise<void>

Creates Express-compatible middleware that automatically tracks usage for API requests.

Parameters:

ParameterTypeRequiredDescription
extractor(req) => { userId, debit } | nullYesExtracts billing info from each request
whiteliststring[]NoEndpoint patterns to track (* single, ** multi segment)
blackliststring[]NoEndpoint patterns to exclude

Example:

app.use(biller.middlewareEventConsumer({
  extractor: (req) => ({
    userId: req.headers["x-user-id"] as string,
    debit: req.body?.cost || 1,
  }),
  blacklist: ["/health", "/api/collect-payment"],
}));

aiTokenStreamConsumer

biller.aiTokenStreamConsumer(
  stream: AsyncIterable<AITokenUsagePayload>
): Promise<StreamEventResponse | undefined>

Stream AI token usage events for billing. Supports a fork mode for simultaneous user streaming.

Parameters:

ParameterTypeDescription
streamAsyncIterable<AITokenUsagePayload>Token usage events to bill
config.returnbooleanIf true, returns a forked stream alongside the response

Example:

async function* generateUsage() {
  yield {
    userId: "user-123",
    model: "gpt-4",
    inputTokens: 100,
    outputTokens: 50,
    inputDebit: 3,
    outputDebit: 6,
  };
}

const response = await biller.aiTokenStreamConsumer(generateUsage());

biller.ai()

Wraps the Vercel AI SDK so every LLM call auto-bills:

import * as ai from "ai";

const aii = biller.ai(ai, {
  inputDebit: mul(biller.tag("GPT_INPUT"), inputTokens()),
  outputDebit: mul(biller.tag("GPT_OUTPUT"), outputTokens()),
});

const result = await aii.streamText({
  userId: "user-123",
  model: openai("gpt-4o-mini"),
  prompt: "Hello",
});

biller.trackAI()

Manually track AI token usage from an onFinish callback:

await biller.trackAI(userId, model, usage, overrides, defaults);

collectPayment

biller.collectPayment(userId: string): Promise<string>

Generate a checkout link for a user to pay their outstanding balance.

Example:

const checkoutLink = await biller.collectPayment("user-123");
res.redirect(checkoutLink);

gRPC API

Scrawn backend exposes three gRPC services for direct integration.

EventService

registerEvent

Registers a billable SDK call event.

Request:

message RegisterEventRequest {
  EventType type = 1;
  string userId = 2;
  oneof data {
    SDKCall sdkCall = 3;
  }
}

message SDKCall {
  SDKCallType sdkCallType = 1;

  oneof debit {
    float amount = 2;
    string tag = 3;
  }
}

enum EventType {
  EVENT_TYPE_UNSPECIFIED = 0;
  SDK_CALL = 1;
}

enum SDKCallType {
  SDKCallType_UNSPECIFIED = 0;
  RAW = 1;
  MIDDLEWARE_CALL = 2;
}

Response:

message RegisterEventResponse {
  string random = 1;
}

Example (TypeScript):

import { createPromiseClient } from '@connectrpc/connect';
import { EventService } from './gen/event/v1/event_connect';
import { EventType, SDKCallType } from './gen/event/v1/event_pb';

const client = createPromiseClient(EventService, transport);

const response = await client.registerEvent({
  type: EventType.SDK_CALL,
  userId: 'user_123',
  data: {
    case: 'sdkCall',
    value: {
      sdkCallType: SDKCallType.RAW,
      debit: {
        case: 'amount',
        value: 100,
      },
    }
  }
});

console.log('Status:', response.random);

Authentication:

  • Requires valid API key in Authorization header as Bearer token
  • API key must start with scrn_

AuthService

createAPIKey

Creates a new API key for authenticated users.

Request:

message CreateAPIKeyRequest {
  string name = 1;
  int64 expiresIn = 2;  // expiration time in seconds from now
}

Response:

message CreateAPIKeyResponse {
  string apiKeyId = 1;
  string apiKey = 2;
  string name = 3;
  string createdAt = 4;
  string expiresAt = 5;
}

Example:

import { createPromiseClient } from '@connectrpc/connect';
import { AuthService } from './gen/auth/v1/auth_connect';

const client = createPromiseClient(AuthService, transport);

const response = await client.createAPIKey({
  name: 'Production Key',
  expiresIn: BigInt(365 * 24 * 60 * 60)  // 1 year in seconds
});

console.log('API Key:', response.apiKey);
console.log('API Key ID:', response.apiKeyId);
console.log('Expires At:', response.expiresAt);

Authentication:

  • Requires valid API key in Authorization header as Bearer token
  • New API key will be generated with scrn_ prefix

Notes:

  • The API key is returned only once during creation
  • Store the API key securely - it cannot be retrieved later
  • The backend stores a hashed version of the API key

PaymentService

Generates a Lemon Squeezy checkout link for a user to pay their outstanding balance.

Request:

message CreateCheckoutLinkRequest {
  string userId = 1;
}

Response:

message CreateCheckoutLinkResponse {
  string checkoutLink = 1;
}

Example:

import { createPromiseClient } from '@connectrpc/connect';
import { PaymentService } from './gen/payment/v1/payment_connect';

const client = createPromiseClient(PaymentService, transport);

const response = await client.createCheckoutLink({
  userId: 'user_123'
});

console.log('Checkout Link:', response.checkoutLink);
// Redirect user to this URL to complete payment

How it works:

  • The backend calculates the user's outstanding balance automatically
  • A custom-priced checkout session is created via Lemon Squeezy
  • The checkout link is valid for immediate use
  • User ID is passed to the payment provider for tracking

Notes:

  • Payment amount is calculated from stored debit events
  • Uses Lemon Squeezy as the payment provider
  • No need to specify amount - it's calculated server-side

Error Handling

SDK Errors

The SDK throws standard JavaScript Error objects with descriptive messages.

Validation Errors:

try {
  await biller.basicUsageEventConsumer({
    userId: "",  // Invalid: empty string
    debit: -5,   // Invalid: negative number
  });
} catch (error) {
  console.error(error.message);
  // "Payload validation failed: userId: must be a non-empty string, debit: must be a positive number"
}

gRPC Connection Errors:

try {
  await biller.basicUsageEventConsumer({
    userId: "user-123",
    debit: 100,
  });
} catch (error) {
  console.error("Failed to register event:", error.message);
}

Backend Error Types

AuthError

Authentication and authorization errors.

Error Types:

TypeCodeDescriptionSolution
MISSING_HEADERUNAUTHENTICATEDNo Authorization header providedInclude Authorization: Bearer <api_key> header
INVALID_HEADER_FORMATUNAUTHENTICATEDAuthorization header format is wrongUse format: Bearer scrn_...
INVALID_API_KEYUNAUTHENTICATEDAPI key is invalid or not foundCheck API key is correct and exists
EXPIRED_API_KEYUNAUTHENTICATEDAPI key has expiredCreate a new API key
REVOKED_API_KEYUNAUTHENTICATEDAPI key has been revokedCreate a new API key
DATABASE_ERRORINTERNALFailed to verify API keyContact support
UNKNOWNINTERNALUnknown authentication errorContact support

EventError

Event registration and processing errors.

Error Types:

TypeCodeDescriptionSolution
INVALID_PAYLOADINVALID_ARGUMENTEvent payload is invalidCheck payload structure matches schema
UNSUPPORTED_EVENT_TYPEINVALID_ARGUMENTEvent type is not supportedUse SDK_CALL event type
VALIDATION_FAILEDINVALID_ARGUMENTZod schema validation failedReview validation error details
INVALID_USER_IDINVALID_ARGUMENTUser ID format is invalidProvide non-empty string for userId
MISSING_DATAINVALID_ARGUMENTRequired field is missingInclude all required fields
INVALID_DATA_FORMATINVALID_ARGUMENTData format doesn't match expectedCheck field types and formats
SERIALIZATION_ERRORINTERNALFailed to store eventContact support
UNKNOWNINTERNALUnknown event processing errorContact support

PaymentError

Payment and checkout link creation errors.

Error Types:

TypeCodeDescriptionSolution
INVALID_USER_IDINVALID_ARGUMENTUser ID is invalidProvide valid non-empty userId
VALIDATION_FAILEDINVALID_ARGUMENTRequest validation failedCheck request parameters
MISSING_API_KEYFAILED_PRECONDITIONLemon Squeezy API key not configuredConfigure LEMON_SQUEEZY_API_KEY env var
MISSING_STORE_IDFAILED_PRECONDITIONLemon Squeezy store ID not configuredConfigure LEMON_SQUEEZY_STORE_ID env var
MISSING_VARIANT_IDFAILED_PRECONDITIONLemon Squeezy variant ID not configuredConfigure LEMON_SQUEEZY_VARIANT_ID env var
PRICE_CALCULATION_FAILEDINTERNALFailed to calculate user's balanceEnsure user has registered events
STORAGE_ADAPTER_FAILEDINTERNALFailed to retrieve data from storageCheck database connectivity
LEMON_SQUEEZY_API_ERRORINTERNALLemon Squeezy API call failedCheck Lemon Squeezy service status
INVALID_CHECKOUT_RESPONSEINTERNALInvalid response from payment providerContact support
CHECKOUT_CREATION_FAILEDINTERNALFailed to create checkout linkReview error details
CONFIGURATION_ERRORFAILED_PRECONDITIONPayment system misconfiguredCheck environment configuration
UNKNOWNINTERNALUnknown payment errorContact support

Error Response Format

All backend errors follow the gRPC Connect error format:

{
  "code": "invalid_argument",
  "message": "Event validation failed: userId: must be a non-empty string",
  "details": []
}

Common gRPC Codes:

  • INVALID_ARGUMENT - Invalid request parameters
  • UNAUTHENTICATED - Authentication failed
  • INTERNAL - Internal server error
  • FAILED_PRECONDITION - Server not properly configured

Types

EventPayload

type Debit<TTag extends string = string> = number | PriceExpr<TTag>;

interface EventPayload<TTag extends string = string> {
  userId: string;
  debit: Debit<TTag>;
  metadata?: Record<string, unknown>;
}

The debit field accepts:

  • A number (cents) — e.g. 500 for $5.00
  • A tag() reference — e.g. tag("PREMIUM_CALL")
  • A pricing expression — e.g. mul(tag("PER_CALL"), 3)

AITokenUsagePayload

interface AITokenUsagePayload<TTag extends string = string> {
  userId: string;
  model: string;
  inputTokens: number;
  outputTokens: number;
  inputDebit: Debit<TTag>;
  outputDebit: Debit<TTag>;
  provider?: string;
  inputCacheTokens?: number;
  inputCacheDebit?: Debit<TTag>;
  outputCacheTokens?: number;
  outputCacheDebit?: Debit<TTag>;
  metadata?: Record<string, unknown>;
}

Middleware Types

interface MiddlewareRequest {
  method?: string;
  url?: string;
  path?: string;
  body?: any;
  headers?: Record<string, string | string[] | undefined>;
  query?: Record<string, any>;
  params?: Record<string, any>;
  [key: string]: any;
}

interface MiddlewareResponse {
  status?: (code: number) => MiddlewareResponse;
  json?: (data: any) => void;
  send?: (data: any) => void;
  [key: string]: any;
}

type MiddlewareNext = (error?: any) => void;

type PayloadExtractor = (req: MiddlewareRequest) =>
  EventPayload | Promise<EventPayload> | null | Promise<null>;

interface MiddlewareEventConfig {
  extractor: PayloadExtractor;
  whitelist?: string[];
  blacklist?: string[];
}

Utilities

matchPath

matchPath(path: string, pattern: string): boolean

Matches a path against a pattern with wildcard support.

Parameters:

ParameterTypeDescription
pathstringThe path to match
patternstringThe pattern with optional wildcards

Wildcard Patterns:

  • * matches single path segment
  • ** matches multiple segments

Example:

import { matchPath } from '@scrawn/core';

matchPath('/api/users', '/api/*'); // true
matchPath('/api/users/123', '/api/*'); // false
matchPath('/api/users/123', '/api/**'); // true
matchPath('/api/v1/users', '/api/v1/*'); // true

Authentication

All API requests require authentication via Bearer token in the Authorization header.

API Key Format:

  • Must start with scrn_ prefix
  • Example: scrn_1234567890abcdef

Using the SDK:

The SDK handles authentication automatically using the apiKey provided to the scrawn() factory:

const biller = scrawn({
  apiKey: process.env.SCRAWN_KEY as `scrn_${string}`,
  baseURL: "http://localhost:8069",
});

Direct gRPC Calls:

When making direct gRPC calls without the SDK, include the API key in the Authorization header:

Authorization: Bearer scrn_your_api_key

Authentication Flow:

  1. Backend receives request with Bearer token
  2. Auth interceptor validates the API key format
  3. API key hash is verified against database
  4. API key expiration is checked
  5. API key ID is added to request context for downstream handlers

Security Notes:

  • API keys are hashed using SHA-256 before storage
  • Original API keys are never stored in the database
  • API keys are cached in memory for performance (with TTL)
  • Expired API keys are automatically rejected

Next Steps