Skip to content

Building Runners

Runners are HTTPS endpoints you host that receive platform events via webhooks. This guide covers setup, deployment, and best practices.

How Runners Work

  1. You register a runner endpoint with the platform
  2. The platform sends signed webhooks when events your manifest subscribes to occur (see Event Subscriptions)
  3. Your runner verifies the signature, processes the event, and responds
  4. Optionally, your runner calls the Action API to interact with the platform

Sequence diagram of the runner round-trip: the platform delivers a signed event to runners whose manifest subscribes to it, with an empty subscription list receiving everything; the runner verifies the HMAC signature, dedupes by eventId, and processes the event, then optionally calls the Action API with its own signed request, which is checked against scopes and rate limits; test.ping is always deliveredSequence diagram of the runner round-trip: the platform delivers a signed event to runners whose manifest subscribes to it, with an empty subscription list receiving everything; the runner verifies the HMAC signature, dedupes by eventId, and processes the event, then optionally calls the Action API with its own signed request, which is checked against scopes and rate limits; test.ping is always delivered

Authenticating to the Developer API

All /api/v2/developer/* endpoints (including runner management below) require a bearer token on Authorization: Bearer <JWT>. Two ways to get one:

  • Web session (JWT): sign in with your wallet at chaindaddy.io/_developer. The dashboard authenticates every developer API call with the session JWT it obtains from the wallet sign-in flow (GET /api/v2/auth/noncePOST /api/v2/auth/login/siwe). If you are scripting against these endpoints directly, reuse that flow to mint a session token.
  • CLI login: run chaindaddy app login — it signs a single-use wallet challenge (POST /api/v2/developer/auth/cli-challengePOST /api/v2/developer/auth/cli-token) and stores the returned credential, which the CLI then sends as its bearer token.

The examples below show a JWT. In practice, most developers never call these endpoints by hand — chaindaddy app init headless scaffolds the runner and registers it for you.

Runner Registration

Register your runner via the API:

bash
curl -X POST "https://api.chaindaddy.io/api/v2/developer/apps/{appId}/runners" \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{
    "endpointUrl": "https://your-server.example.com/webhook",
    "authType": "hmac-sha256",
    "healthCheckPath": "/health"
  }'

The events your runner receives are NOT part of this registration call — they come from your app manifest's runner.events list (see Event Subscriptions).

Response:

json
{
  "id": "runner-uuid",
  "appId": "my-app",
  "endpointUrl": "https://your-server.example.com/webhook",
  "authType": "hmac-sha256",
  "webhookSecret": "a1b2c3d4e5f6...",
  "healthStatus": "unknown",
  "createdAt": "2025-01-15T10:00:00Z"
}

Save the Secret

The webhookSecret is only returned once. Store it securely as an environment variable.

Event Subscriptions

Events are delivered to runners whose manifest subscribes to the event: the platform filters every dispatch against the runner.events list in your app's live (latest approved) manifest.

  • Both the legacy short form (app.enabled, app.disabled, app.configured) and the canonical wire form (token.app.enabled, token.app.disabled, token.app.configured) match each other — declare either and you receive the canonical wire event.
  • An empty or absent events list receives everything (back-compat for manifests written before filtering).
  • A non-empty list receives only the events it names (after alias normalization). All other event types require an exact name match — see the Event Reference for names.
  • test.ping sent by the verify endpoint is always delivered regardless of your subscription list — it's a connectivity check, not a platform event.
  • Apps without an approved submission yet (dev-preview installs) are not filtered: all events are delivered.

To change your subscriptions, submit a new app version with an updated runner.events list — the filter follows the latest approved manifest.

Management API

All endpoints require Authorization: Bearer <JWT> (see Authenticating to the Developer API).

MethodEndpointDescription
POST/api/v2/developer/apps/{appId}/runnersRegister a new runner
GET/api/v2/developer/apps/{appId}/runnersList all runners
DELETE/api/v2/developer/apps/{appId}/runners/{runnerId}Delete a runner
POST/api/v2/developer/apps/{appId}/runners/{runnerId}/verifyVerify connectivity

Verification

After registering, verify your runner is reachable:

bash
curl -X POST "https://api.chaindaddy.io/api/v2/developer/apps/{appId}/runners/{runnerId}/verify" \
  -H "Authorization: Bearer <JWT>"

This sends a test.ping event to your endpoint. If your runner responds with {"status":"ok"}, its health status updates to healthy.

Verification also resets the circuit breaker if it was open due to consecutive failures.

HMAC Signature Verification

For hmac-sha256 auth, the platform signs each request body. Your runner must verify the signature before processing.

Algorithm:

  1. Hex-decode the webhook secret into bytes
  2. Compute HMAC-SHA256 of the raw request body using the secret
  3. Compare the hex digest with the X-App-Signature header
  4. Use constant-time comparison to prevent timing attacks
typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySignature(secret: string, body: string, signature: string): boolean {
  const key = Buffer.from(secret, 'hex');
  const expected = createHmac('sha256', key).update(body, 'utf8').digest('hex');
  if (expected.length !== signature.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
typescript
async function verifySignature(secret: string, body: string, signature: string): Promise<boolean> {
  const key = hexToBytes(secret);
  const cryptoKey = await crypto.subtle.importKey(
    'raw', key.buffer, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
  );
  const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(body));
  const expected = bytesToHex(new Uint8Array(sig));
  // Constant-time comparison
  if (expected.length !== signature.length) return false;
  let result = 0;
  for (let i = 0; i < expected.length; i++) {
    result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
  }
  return result === 0;
}

Health Check

If you declared healthCheckPath in your manifest, the platform periodically pings:

GET {endpointUrl}{healthCheckPath}

Expected response:

json
{
  "status": "healthy",
  "version": "1.0.0"
}
StatusMeaning
healthyRunner is operational
degradedRunner is working but with reduced capacity
unhealthyRunner cannot process events

Idempotency

Events may be delivered more than once. Use the eventId field to deduplicate:

  1. Before processing, check if eventId has been seen before
  2. If yes, return {"status":"ok","message":"duplicate event, skipped"}
  3. If no, process the event, then store the eventId with a 24-hour TTL

Using the SDK:

typescript
import { withIdempotency, MemoryStore } from '@chaindaddy/apps/runner';

const store = new MemoryStore(); // Use DynamoDB/Redis in production
const handler = withIdempotency(async (event) => {
  // Your logic here...
  return { status: 'ok' };
}, store);

DynamoDB (Lambda): Use conditional PutItem with attribute_not_exists for atomic dedup.

KV (Workers): Check kv.get(eventId) before processing, then kv.put(eventId, '1', { expirationTtl: 86400 }).

Reference Implementations

Complete, deployable starter templates are available:

AWS Lambda

SAM template with API Gateway, Lambda (Node.js 20), and DynamoDB for idempotency.

bash
cd examples/lambda-runner
npm install
sam build
sam deploy --parameter-overrides WebhookSecret=YOUR_SECRET

Express + Docker

Express server with Dockerfile for containerized deployment.

bash
cd examples/express-runner
npm install
cp .env.example .env  # Configure your secret
npm run dev            # Development with auto-reload
docker-compose up      # Production via Docker

Cloudflare Worker

Worker with KV namespace for idempotency.

bash
cd examples/cloudflare-runner
npm install
wrangler secret put WEBHOOK_SECRET
wrangler kv namespace create IDEMPOTENCY
wrangler deploy

All three example projects ship with the chaindaddy app init headless scaffold (also available with the developer beta — sign up at chaindaddy.io/_developer).

Environment Variables

All runner examples use consistent environment variables:

VariableDescription
WEBHOOK_SECRETHex-encoded secret from runner registration
APP_IDYour app identifier
API_BASE_URLPlatform API URL (default: https://api.chaindaddy.io)