Skip to content

Runners and events ​

A runner is an HTTPS endpoint you host. The platform sends it signed events from the coin pages your app is on, and the runner can keep per-token data through the Action API. Most apps need none: an app that sells, signs players in or talks to its own server does all of that from the page (Actions). Add a runner when your own service must hear about a page with nobody on it.

An app with a runner has "type": "headless" (a runner alone) or "full" (a widget and a runner) in its manifest. chaindaddy app init headless my-runner scaffolds a plain Node server that checks signatures; deploy it anywhere that serves HTTPS. Deployable starters for AWS Lambda, Express with Docker and Cloudflare Workers are in the SDK repository's examples/. The runner SDK (npm install @chaindaddy/apps@beta) is optional.

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

Register a runner ​

The developer API key ​

The /api/v2/developer/* endpoints take a developer API key (chaindaddy keys create): send Authorization: Bearer cd_live_… plus X-API-Key: cd_live_…. Registering and verifying a runner need the current Developer Beta Program Agreement signed.

The call ​

bash
curl -X POST "https://api.chaindaddy.io/api/v2/developer/apps/{appId}/runners" \
  -H "Authorization: Bearer $CD_LIVE_KEY" -H "X-API-Key: $CD_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "endpoint_url": "https://your-server.example.com/webhook", "auth_type": "hmac-sha256" }'

The 201 answer carries the runner's id, its health_status and, once only, its shared_secret. Store the secret as an environment variable (WEBHOOK_SECRET in the examples).

MethodEndpointDescription
POST/api/v2/developer/apps/{appId}/runnersRegister a runner
GET/api/v2/developer/apps/{appId}/runnersList your runners, with each one's health and consecutive failures
DELETE/api/v2/developer/apps/{appId}/runners/{runnerId}Delete a runner
POST/api/v2/developer/apps/{appId}/runners/{runnerId}/verifySend a test.ping and answer reachable, status_code and latency_ms

Authentication methods ​

auth_type (the manifest's authType) decides how both directions are authenticated.

auth_typePlatform to runnerRunner to platform
hmac-sha256 (recommended)X-App-Signature: the hex HMAC-SHA256 of the raw body, keyed with the hex-decoded secret the platform generatedA signed call: see Signing a call
bearerAuthorization: Bearer {token}, a token you gave in auth_config: { "token": "…" }The same header
custom-headerYour header, from auth_config: { "header_name": "…", "header_value": "…" }The same header

hmac-sha256 proves both that a request came from the platform and that its body was not changed.

Verification ​

POST …/runners/{runnerId}/verify sends a test.ping. A 2xx answer marks the runner healthy and turns delivery back on if repeated failures had stopped it.

Receiving events ​

The platform filters every delivery against the runner.events list in your app's live (latest approved) manifest.

  • The short forms app.enabled, app.disabled and app.configured match token.app.enabled, token.app.disabled and token.app.configured. Declare either; deliveries carry the full name.
  • An empty or absent list receives everything. A non-empty list receives only the events it names.
  • test.ping is always delivered. An app with no approved build yet is not filtered.
  • To change your subscriptions, submit a new version.

Check the signature first. With hmac-sha256, compare the X-App-Signature header with the HMAC of the raw body, in constant time:

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));
}

Answer with any 2xx status within 30 seconds. The body is not read. Log an event type you don't recognize and answer 200, so your runner keeps working as the list grows.

Idempotency ​

An event may be delivered more than once. Before processing, check whether you have seen its eventId; if so, answer 200 and skip it. Otherwise process it and remember the id for 24 hours. The SDK's withIdempotency (@chaindaddy/apps/runner) wraps a handler with this, over a store you supply.

Retry policy ​

A failed delivery (an error, or no answer in time) is retried after 1 minute, 5 minutes and 30 minutes, then marked failed. After 10 failed deliveries in a row the platform stops delivering to the runner; a successful verification turns it back on.

The event envelope ​

json
{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventType": "token.profile.updated",
  "timestamp": "2026-01-15T10:30:00Z",
  "appId": "4e82adad-1cf0-4214-a4d6-b324c5f60442/profile-sync",
  "crownId": "42",
  "chainId": "eip155:42161",
  "crownConfig": { "threshold": 0.05, "channel": "#alerts" },
  "payload": { }
}
FieldDescription
eventIdUnique UUID. Use it for idempotency
eventTypeOne of the event types
timestampISO 8601 time the event was generated
appIdYour app ID
crownIdThe coin page's registration id: the number Chain Daddy gave the page on its chain
chainIdThe coin page's chain, as a CAIP-2 id (for example eip155:42161)
crownConfigThe token owner's settings for your app on this page (App config). {} if not configured. Not present on test.ping
payloadEvent-specific data

The headers X-App-Event-ID, X-App-Event-Type and X-App-Timestamp repeat eventId, eventType and timestamp.

Event types ​

EventWhenpayload
token.profile.updatedThe coin page's profile was savedcrownId, symbol, chain, updatedFields (the profile fields that changed)
token.heartbeat.warningThe registration is at risk of going inactivecrownId, symbol, chainKey, currentScore, threshold, timestamp
token.heartbeat.criticalThe registration went inactivecrownId, symbol, chainKey, currentScore, daysToInactive, timestamp
token.heartbeat.recoveredThe registration's heartbeat recoveredcrownId, symbol, chainKey, currentScore, previousScore, timestamp
token.app.enabledA token owner enabled your app on their pagesymbol, chainKey, appId, appName, enabledBy, config (always {}; the settings are in crownConfig)
token.app.disabledA token owner disabled your app on their pagesymbol, chainKey, appId, appName, disabledBy
token.app.configuredThe owner or a manager saved your app's settingscrownId, chainKey (a CAIP-2 id), newConfig (the config as saved, not the previous one or who saved it)
test.pingVerification. Its envelope has no crownConfig, and empty crownId and chainIdmessage

In the heartbeat warning and critical events the scores are fixed values, so act on the event type, not the numbers.

json
{ "crownId": "42", "symbol": "DOGE", "chain": "eip155:42161", "updatedFields": ["description", "website"] }

Runner Action API ​

A runner calls the platform directly. What is live today is per-token storage, under the apps:storage:write scope in the manifest's runner.actions.

https://api.chaindaddy.io/api/v2/apps/{appId}/registrations/{id}/actions/{action}?chain={chain}

{appId} is the app the runner is registered under, {id} the coin page's registration id and {chain} its chain (a chain key such as base, a chain id or eip155:…). Always send ?chain=: storage is kept per chain. Every call carries your runner's credential (Authentication methods); no API key is needed. An answer is { "success": true, "action_id": "…", "status": "executed", "data": { } }, and a refusal is an HTTP error with { "error": "…" }. Request bodies are capped at 1 MB.

Signing a call ​

With hmac-sha256, send two headers:

  • X-App-Timestamp: the current Unix time in seconds.

  • X-App-Signature: v2= and the lowercase hex HMAC-SHA256, keyed with your hex-decoded secret, of these five lines joined by \n:

    v2
    {X-App-Timestamp}
    {method, upper case}
    {path and query, exactly as sent}
    {raw body; empty for a GET or DELETE}

The signature works for that one call only, and is accepted for 5 minutes either side of the platform's clock. The SDK's createActionClient (@chaindaddy/apps/runner) signs for you.

typescript
import { createHmac } from 'node:crypto';

async function callAction(method: string, url: string, body = '') {
  const u = new URL(url);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signed = ['v2', timestamp, method, u.pathname + u.search, body].join('\n');
  const signature = createHmac('sha256', Buffer.from(process.env.WEBHOOK_SECRET!, 'hex'))
    .update(signed, 'utf8')
    .digest('hex');
  return fetch(u, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'X-App-Timestamp': timestamp,
      'X-App-Signature': `v2=${signature}`,
    },
    ...(body ? { body } : {}),
  });
}

await callAction('POST', `${base}/apps.storage.set?chain=base`,
  JSON.stringify({ key: 'lastAlert', value: { price: 0.1 } }));
await callAction('GET', `${base}/apps.storage.get?key=lastAlert&chain=base`);
StatuserrorWhen
400token id must be a positive number
401no runners registered for this app
401X-App-Signature must be v2=<hex>: …a signature without v2=, such as the body-only one used before 2026-10-01
401X-App-Timestamp must be the Unix time in seconds, within 5 minutes of the server's clock
401invalid runner credentialsno runner of the app matches the credential
403runner is temporarily suspended due to rate limit abusesee Rate limits
403this app is not installed on this token pagethe coin page, on that chain, does not have your app

Storage ​

Key-value storage your runner keeps per app, per coin page and chain: one app cannot read another's. Values are any JSON.

Limits: 100 keys, 10,240 bytes per value (as sent), 512,000 bytes in all, per app per coin page. Keys are up to 255 characters.

CallRequestdata
POST …/apps.storage.set{ "key": "lastAlert", "value": { "price": 0.1 } }none
GET …/apps.storage.get?key=lastAlert{ "key", "value", "updated_at" }
GET …/apps.storage.list{ "keys": ["lastAlert", …] }
DELETE …/apps.storage.delete?key=lastAlertnone; also for a key that does not exist
Statuserror
400request must include 'key' (string) and 'value' (JSON)
400key query parameter is required
400maximum of 100 keys per app+crown exceeded
400value exceeds maximum size of 10240 bytes
400total storage exceeds maximum of 512000 bytes
404key not found

Rate limits ​

Storage calls are limited to 120 a minute per runner, in a sliding window. Every call that passed authentication carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Past the limit the answer is 429 {"error":"rate limit exceeded"} with Retry-After in seconds: wait that long, then retry. A runner refused for its rate 10 times within 5 minutes is suspended for an hour, and every call answers 403 until then.

Action log ​

Every runner action is logged, executed or refused, and the token's owner and managers can read the log, signed in: GET /api/v2/registration/{id}/app-actions, filtered by app_id, action_type (for example apps.storage.set), status (executed, denied or failed), start_date and end_date (RFC 3339), with limit (default 50) and offset.