Appearance
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
- You register a runner endpoint with the platform
- The platform sends signed webhooks when events your manifest subscribes to occur (see Event Subscriptions)
- Your runner verifies the signature, processes the event, and responds
- Optionally, your runner calls the Action API to interact with the platform
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/nonce→POST /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-challenge→POST /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
eventslist 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.pingsent 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).
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v2/developer/apps/{appId}/runners | Register a new runner |
| GET | /api/v2/developer/apps/{appId}/runners | List all runners |
| DELETE | /api/v2/developer/apps/{appId}/runners/{runnerId} | Delete a runner |
| POST | /api/v2/developer/apps/{appId}/runners/{runnerId}/verify | Verify 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:
- Hex-decode the webhook secret into bytes
- Compute HMAC-SHA256 of the raw request body using the secret
- Compare the hex digest with the
X-App-Signatureheader - 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"
}| Status | Meaning |
|---|---|
healthy | Runner is operational |
degraded | Runner is working but with reduced capacity |
unhealthy | Runner cannot process events |
Idempotency
Events may be delivered more than once. Use the eventId field to deduplicate:
- Before processing, check if
eventIdhas been seen before - If yes, return
{"status":"ok","message":"duplicate event, skipped"} - If no, process the event, then store the
eventIdwith 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_SECRETExpress + 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 DockerCloudflare 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 deployAll 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:
| Variable | Description |
|---|---|
WEBHOOK_SECRET | Hex-encoded secret from runner registration |
APP_ID | Your app identifier |
API_BASE_URL | Platform API URL (default: https://api.chaindaddy.io) |