Webhooks

Webhooks are how ARKH communicates with your server. When a user performs a gesture or interacts with your app, we send an HTTP POST request to your webhook URL with event details.

Configure your webhook URL in the Apps page when creating or editing an app.

Event Types

ARKH sends webhook events for various actions. The events differ between app types: Control and Device apps receive gesture and lifecycle events, while Preferred Notification apps receive response and slot events.

Event

Description

gesture

Sent when a user performs a gesture. Contains the gesture type.

app.installed

Sent when a user adds your app to their collection.

app.activated

Sent when your app becomes active. For controls: selected in hotbar. For devices: ring mode activated.

control.passive

Control apps only. Sent when the ring enters passive mode (screen locked, app backgrounded). Gestures pause but vibes still work.

app.deactivated

Sent when your app is no longer active. For controls: user switched to another control. For devices: ring mode deactivated.

app.uninstalled

Sent when a user removes your app from their collection.

notification.responded

Notification apps only. Sent when a user responds to a notification triggered with a response_config. Contains the user's answer and the original event_id.

App Lifecycle

All apps follow the same basic lifecycle. The events below are sent as users interact with your app.

Event

Trigger

app.installed

User adds app to their collection

app.activated

User activates the app (selects in hotbar, starts session, etc.)

control.passive

Control apps only. Ring enters passive mode (screen locked, app backgrounded). Gestures pause but vibes still work.

notification.responded

Preferred Notification apps only. User responded to a vibe trigger on their ring (yes, no, emoji, or dismiss).

app.deactivated

User deactivates the app (selects different control, ends session, etc.)

app.uninstalled

User removes app from their collection

Payload Structure

All webhook payloads use a consistent, lean structure. The user_app_id is a privacy-preserving identifier unique to each user-app installation.

Base Payload Structure

{
  "event": "app.activated",
  "user_app_id": "550e8400-e29b-41d4-a716-446655440000",
  "app_id": "app_01234567-89ab-cdef-0123-456789abcdef",
  "timestamp": "2024-01-15T14:30:00Z",
  "data": {}
}

For gesture events, the gesture_type is included at the top level:

gesture Event

{
  "event": "gesture",
  "gesture_type": "swipe_up",
  "user_app_id": "550e8400-e29b-41d4-a716-446655440000",
  "app_id": "app_01234567-89ab-cdef-0123-456789abcdef",
  "timestamp": "2024-01-15T14:30:00Z",
  "data": {}
}

If the user has linked their account via ARKH Connect, webhooks also include your external_user_id:

Webhook with external_user_id (Connected User)

{
  "event": "gesture",
  "gesture_type": "swipe_up",
  "user_app_id": "550e8400-e29b-41d4-a716-446655440000",
  "external_user_id": "user_123",
  "app_id": "app_01234567-89ab-cdef-0123-456789abcdef",
  "timestamp": "2024-01-15T14:30:00Z",
  "data": {}
}

User Identification

user_app_id is always present — use it in API calls like /vibes/trigger. external_user_id is only present for connected users and contains your identifier for easy database lookups.

Webhook Secret

Each app has a unique webhook secret that ARKH uses to sign webhook payloads. This allows you to verify that incoming webhooks are authentic and haven't been tampered with.

A webhook secret is generated when you create an app. Store it securely on your server—you'll need it to verify incoming webhooks. You can view or regenerate your secret from the Apps page.

Note: Your webhook secret is automatically regenerated whenever you change your webhook URL. This ensures that secrets used during development don't carry over to production. Always update your server with the new secret after changing your webhook URL.

Keep Your Secret Safe

Store your webhook secret securely in environment variables. Never commit it to version control or expose it in client-side code.

Signature Verification

ARKH signs all webhook payloads using HMAC-SHA256. Verify the signature to ensure the request is authentic and protect against spoofed requests.

Each request includes two headers:

Header

Description

X-ARKH-Signature

HMAC-SHA256 signature of the payload, prefixed with sha256=

X-ARKH-Timestamp

Unix timestamp when the webhook was sent (for replay attack prevention)

Webhook Headers

X-ARKH-Signature: sha256=5d41402abc4b2a76b9719d911017c592
X-ARKH-Timestamp: 1705330200
Content-Type: application/json

To verify the signature:

Verify Signature

const crypto = require('crypto');

function verifyWebhook(payload, signature, timestamp, secret) {
  // Check timestamp is within 5 minutes
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false;
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${expectedSig}`)
  );
}

Security Best Practice

Always verify webhook signatures before processing events. This prevents attackers from spoofing webhook requests to your server.

Privacy Controls

Users can disable gesture forwarding or vibes by tapping your app in the widget manager. If gestures are disabled, your webhook won't be called for gesture events. If vibes are disabled, trigger requests will return a permission_disabled error.

Responding to Webhooks

Your server should respond with a 2xx status code within 30 seconds to acknowledge receipt. If we don't receive a response, we'll retry the webhook up to 3 times with exponential backoff (1s, 5s, 30s delays).

For gesture events, you can include a response body to trigger immediate feedback:

Webhook Response with Vibe

{
  "success": true,
  "vibe": {
    "sequence": [{ "type": "tap" }],
    "message": "Action completed",
    "show_ring_ui_notif": true
  }
}

Set show_ring_ui_notif: true to display a 5-second visual notification on the user's Ring UI (Dynamic Island, Lock Screen, or in-app toast) alongside the haptic feedback.

Notification Responses (Notification Apps)

If your app type is preferred_notification, you can request a response from the user by including a response_config in your trigger payload. Responses work in all iPhone states: foreground, background, and lock screen. The user interacts directly on the ring or via notification action buttons.

There are three ways to receive the response, controlled by the response_mode field:

Response Mode Comparison

// "webhook" (default) — fire-and-forget; response arrives at your webhook_url
POST /vibes/trigger { ..., response_mode: "webhook" }
→ returns { event_id, response_mode: "webhook", ... } immediately
→ notification.responded webhook fires when user responds

// "poll" — return immediately; you poll until answered
POST /vibes/trigger { ..., response_mode: "poll" }
→ returns { event_id, response_mode: "poll", ... } immediately
→ GET /events/:event_id/response → { status: "pending" } or { status: "responded", response: "yes" }

// "await" — hold HTTP connection open; response inline in the trigger reply
POST /vibes/trigger { ..., response_mode: "await", timeout_ms: 30000 }
→ blocks until user responds or timeout_ms elapses
→ returns { event_id, response_mode: "await", response: { status: "responded", value: "yes", ... } }
   or    { event_id, response_mode: "await", response: { status: "timeout", value: null } }
   or    { success: false, event_id: null, response: { status: "not_enabled", value: null } }  // user has notifications off

// "not_enabled" — returned immediately (all modes) when the user hasn't enabled your app's notifications.
// This covers: no priority slot assigned, muted, ring off, or companion app closed.
// No event_id is issued. The specific reason is not exposed.

await Mode — Synchronous Response Pattern

const resp = await fetch("https://developer.arkh.com/api/vibes/trigger", {
  method: "POST",
  headers: { "Authorization": "Bearer arkh_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    user_app_id: "@me",
    app_id: "app_01234...",
    sequence: [{ type: "pulse" }],
    message: "Did you finish your workout?",
    show_ring_ui_notif: true,
    response_config: { type: "yes_no_dismiss" },
    response_mode: "await",
    timeout_ms: 30000,
  }),
});
const body = await resp.json();
const { response } = body;

if (!body.success && response?.status === "not_enabled") {
  console.log("User hasn't enabled notifications for this app");
} else if (response.status === "responded") {
  console.log("User answered:", response.value); // "yes" | "no" | "dismiss"
} else {
  console.log("No response within 30s");
}

poll Mode — Store event_id, Poll Until Answered

// Step 1: trigger, get event_id back immediately
const { event_id } = await fetch("https://developer.arkh.com/api/vibes/trigger", {
  method: "POST",
  headers: { "Authorization": "Bearer arkh_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    user_app_id: "@me",
    app_id: "app_01234...",
    sequence: [{ type: "pulse" }],
    message: "Did you finish your workout?",
    show_ring_ui_notif: true,
    response_config: { type: "yes_no_dismiss" },
    response_mode: "poll",
  }),
}).then(r => r.json());

// Step 2: poll until responded (e.g. every 2s for up to 5 minutes)
let result;
for (let i = 0; i < 150; i++) {
  result = await fetch(
    `https://developer.arkh.com/api/events/${event_id}/response`,
    { headers: { "Authorization": "Bearer arkh_key" } }
  ).then(r => r.json());

  if (result.status === "responded") break;
  await new Promise(r => setTimeout(r, 2000));
}

console.log(result); // { event_id, status: "responded", response: "yes", responded_at: "..." }

webhook Mode — Store event_id, Handle in Webhook (Default)

// Step 1: trigger (response_mode defaults to "webhook")
POST /vibes/trigger
{
  "user_app_id": "550e8400-...",
  "app_id": "app_01234...",
  "sequence": [{ "type": "pulse" }, { "type": "tap" }],
  "message": "Did you finish your workout?",
  "show_ring_ui_notif": true,
  "response_config": { "type": "yes_no_dismiss" }
}
// → { event_id: "evt_abc123...", response_mode: "webhook", ... }
// Store event_id to correlate with incoming webhook

// Step 2: when user responds, notification.responded fires to your webhook_url:
{
  "event": "notification.responded",
  "user_app_id": "550e8400-e29b-41d4-a716-446655440000",
  "app_id": "app_01234567-89ab-cdef-0123-456789abcdef",
  "timestamp": "2026-02-25T14:30:00Z",
  "data": { "event_id": "evt_abc123...", "response": "yes" }
}

Response Config Types

// type options:
"yes_no_dismiss"  // Three actions: Confirm / Reject / Dismiss
"emoji"           // Emoji picker — specify up to 5 emojis with labels
"none"            // No response expected (default)

// Response values:
//   yes_no_dismiss → "yes" (confirm) | "no" (reject) | "dismiss"
//   emoji          → "emoji:<value>"  e.g. "emoji:👍"

// emoji example — use any emojis, pick your own labels:
{
  "type": "emoji",
  "options": [
    { "emoji": "👍", "label": "Nice" },
    { "emoji": "❤️", "label": "Love" },
    { "emoji": "🔥", "label": "Fire" },
    { "emoji": "😮", "label": "Wow" }
  ]
}
// Rules enforced by the API:
//   • 2–5 options (truncated to 5 if exceeded; dropped options listed in warnings[])
//   • Labels ≤15 chars (truncated if longer; originals listed in warnings[])
//   • warnings[] is present in the 200 response whenever truncation occurs

notification.responded Webhook Payload

{
  "event": "notification.responded",
  "user_app_id": "550e8400-e29b-41d4-a716-446655440000",
  "app_id": "app_01234567-89ab-cdef-0123-456789abcdef",
  "timestamp": "2026-02-25T14:30:00Z",
  "data": {
    "event_id": "evt_abc123...",
    "response": "yes"
  }
}

// response values:
//   "yes"           (user confirmed)
//   "no"            (user rejected)
//   "dismiss"       (user dismissed)
//   "emoji:<value>" (e.g. "emoji:👍")

Avoiding Double Processing

The notification.responded webhook fires for every response, regardless of response_mode. If your app has a webhook_url configured and you use await or poll, your server will receive both the inline/polled result and the webhook. Use event_id as a deduplication key to avoid processing the same response twice. To suppress webhooks entirely, leave webhook_url blank on your app.

Deduplication Pattern (Node.js + Redis)

// In your webhook handler:
app.post("/webhook", async (req, res) => {
  const { event, data } = req.body;
  if (event !== "notification.responded") { /* handle other events */ return; }

  const { event_id, response } = data;

  // Deduplicate: skip if already processed via await/poll
  const alreadyProcessed = await redis.get(`response:${event_id}`);
  if (alreadyProcessed) return res.sendStatus(200);
  await redis.set(`response:${event_id}`, 1, { ex: 86400 }); // expire after 24h

  // Process the response
  await handleResponse(event_id, response);
  res.sendStatus(200);
});

Local Development

During development, you'll likely want to receive webhooks on your local machine. Since ARKH can't reach localhost directly, you'll need to expose your local server using a tunnel service.

Security Considerations

Tunnel services expose your local machine to the internet. Understand the risks before using them, and never leave tunnels running unattended. Only use these tools for development—never in production.

Common solutions:

Once you have a public URL, paste it into your app's webhook URL field in the Apps page. The tunnel will forward ARKH webhook requests to your local server.

Before Going to Production

When you're ready to deploy:

  1. Update your webhook URL to your production server's public endpoint.

  2. Regenerate your webhook secret from the Apps page. Your development secret may have been exposed in logs or shared during testing.

  3. Stop your development tunnel. Never leave tunnels running longer than needed.

You can monitor webhook delivery in real-time using the Console, which shows all events sent to your app including payloads, responses, and latency.