Appearance
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:
- Serialize the request body as JSON
- Compute HMAC-SHA256 using your hex-decoded webhook secret
- Include the hex signature in the
X-App-Signatureheader
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.setjson
{ "key": "lastAlert", "value": "{\"price\":0.10,\"time\":\"2025-01-15\"}" }Get a Value
GET .../actions/apps.storage.get?key=lastAlertResponse:
json
{ "success": true, "data": { "key": "lastAlert", "value": "{\"price\":0.10}" } }List Keys
GET .../actions/apps.storage.listResponse:
json
{ "success": true, "data": { "keys": ["lastAlert", "config", "state"] } }Delete a Key
DELETE .../actions/apps.storage.delete?key=lastAlertNotifications
Send notifications to the token owner's dashboard.
POST .../actions/notifications.sendjson
{
"title": "Price Alert",
"body": "DOGE crossed $0.10 threshold",
"severity": "info",
"actionUrl": "/doge/arbitrum"
}| Field | Type | Limit | Required |
|---|---|---|---|
title | string | 100 chars | Yes |
body | string | 500 chars | Yes |
severity | string | info, warning, critical | No (default: info) |
actionUrl | string | Internal path | No |
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.updatejson
{
"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.updatejson
{
"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.initiatejson
{
"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.managejson
{
"action": "enable",
"appId": "another-app",
"config": { "setting": "value" }
}Rate Limits
Actions are rate-limited per runner with sliding windows:
| Category | Limit | Scopes |
|---|---|---|
| Read | 60/minute | crown:read, market:read, apps:read |
| Write | 30/minute | crown:metadata:write, apps:config:write |
| Storage | 120/minute | apps:storage:write (set, get, list, delete) |
| Notifications | 30/minute | notifications:send |
| Dangerous | 5/minute | crown:transfer:initiate, crown:apps:manage |
Rate Limit Headers
Every response includes rate limit headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests in the current window |
X-RateLimit-Remaining | Requests remaining |
X-RateLimit-Reset | UTC epoch seconds when the window resets |
Retry-After | Seconds 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');