Skip to content

Actions API

The Action API lets runners interact with the platform, store data, send notifications, update metadata, and more. All requests are authenticated with the same HMAC secret used for webhooks.

Authentication

Sign outgoing requests the same way the platform signs webhooks:

  1. Serialize the request body as JSON
  2. Compute HMAC-SHA256 using your hex-decoded webhook secret
  3. Include the hex signature in the X-App-Signature header
typescript
import { createHmac } from 'node:crypto';

function sign(secret: string, body: string): string {
  const key = Buffer.from(secret, 'hex');
  return createHmac('sha256', key).update(body, 'utf8').digest('hex');
}

const body = JSON.stringify({ key: 'lastCheck', value: '2025-01-15' });
const signature = sign(process.env.WEBHOOK_SECRET!, body);

const response = await fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-App-ID': 'my-app',
    'X-App-Signature': signature,
  },
  body,
});

Base URL

All action endpoints follow this pattern:

POST /api/v2/apps/{appId}/registrations/{id}/actions/{action}

Response Format

Every action returns a standard response:

json
{
  "success": true,
  "action_id": "act-550e8400",
  "status": "completed",
  "data": { ... }
}

For dangerous actions that require approval:

json
{
  "success": true,
  "action_id": "act-550e8400",
  "status": "pending_approval",
  "approval_id": "apr-550e8400"
}

Storage

Scoped key-value storage for persisting state between event invocations. Each app gets isolated storage per token.

Limits: 100 keys per app per token, 10KB per value, 500KB total.

Set a Value

POST .../actions/apps.storage.set
json
{ "key": "lastAlert", "value": "{\"price\":0.10,\"time\":\"2025-01-15\"}" }

Get a Value

GET .../actions/apps.storage.get?key=lastAlert

Response:

json
{ "success": true, "data": { "key": "lastAlert", "value": "{\"price\":0.10}" } }

List Keys

GET .../actions/apps.storage.list

Response:

json
{ "success": true, "data": { "keys": ["lastAlert", "config", "state"] } }

Delete a Key

DELETE .../actions/apps.storage.delete?key=lastAlert

Notifications

Send notifications to the token owner's dashboard.

POST .../actions/notifications.send
json
{
  "title": "Price Alert",
  "body": "DOGE crossed $0.10 threshold",
  "severity": "info",
  "actionUrl": "/doge/arbitrum"
}
FieldTypeLimitRequired
titlestring100 charsYes
bodystring500 charsYes
severitystringinfo, warning, criticalNo (default: info)
actionUrlstringInternal pathNo

Metadata

Update token metadata extensions. Requires crown:metadata:write scope (the scope name keeps the historical crown: prefix on the wire).

POST .../actions/crown.metadata.update
json
{
  "metadata": {
    "myApp_score": 95,
    "myApp_lastSync": "2025-01-15T10:00:00Z"
  }
}

Metadata is merged with existing values. Keys should be namespaced with your app ID to avoid conflicts.

Config

Update app configuration. Requires apps:config:write scope.

POST .../actions/apps.config.update
json
{
  "config": {
    "alertPrice": 0.15,
    "alertDirection": "above"
  }
}

Dangerous Actions

These actions always require explicit approval from the token owner via the dashboard.

Initiate Transfer

POST .../actions/crown.transfer.initiate
json
{
  "targetAddress": "0x5678...efgh",
  "reason": "Migration to new wallet"
}

Returns status: "pending_approval" with an approval_id. The token owner must approve in their dashboard within 24 hours.

Manage Apps

POST .../actions/crown.apps.manage
json
{
  "action": "enable",
  "appId": "another-app",
  "config": { "setting": "value" }
}

Rate Limits

Actions are rate-limited per runner with sliding windows:

CategoryLimitScopes
Read60/minutecrown:read, market:read, apps:read
Write30/minutecrown:metadata:write, apps:config:write
Storage120/minuteapps:storage:write (set, get, list, delete)
Notifications30/minutenotifications:send
Dangerous5/minutecrown:transfer:initiate, crown:apps:manage

Rate Limit Headers

Every response includes rate limit headers:

HeaderDescription
X-RateLimit-LimitMaximum requests in the current window
X-RateLimit-RemainingRequests remaining
X-RateLimit-ResetUTC epoch seconds when the window resets
Retry-AfterSeconds to wait (only on 429 responses)

Handling 429 Responses

When rate limited, wait for the Retry-After duration before retrying:

typescript
const response = await fetch(url, { ... });

if (response.status === 429) {
  const retryAfter = parseInt(response.headers.get('Retry-After') || '5');
  await new Promise(r => setTimeout(r, retryAfter * 1000));
  // Retry the request
}

The SDK's createActionClient handles this automatically with configurable retry limits.

Using the SDK

The SDK provides a typed client that handles signing and rate limiting:

typescript
import { createActionClient } from '@chaindaddy/apps/runner';

const client = createActionClient({
  apiBaseUrl: 'https://api.chaindaddy.io',
  appId: 'my-app',
  secret: process.env.WEBHOOK_SECRET!,
  maxRetries: 3, // Auto-retry on 429
});

// Storage
await client.storage.set('doge.arb', 'key', 'value');
const value = await client.storage.get('doge.arb', 'key');
const keys = await client.storage.list('doge.arb');
await client.storage.delete('doge.arb', 'key');

// Notifications
await client.notifications.send('doge.arb', {
  title: 'Alert',
  body: 'Something happened',
  severity: 'warning',
});

// Metadata
await client.metadata.update('doge.arb', { score: 95 });

// Dangerous (returns approval_id)
const result = await client.dangerous.initiateTransfer('doge.arb', '0x...', 'Reason');