Surflet

Callbacks & Events

Receive callbacks for user actions

When a user takes an action on a Surflet page (such as clicking an approval button or submitting a form), Surflet delivers the event to your registered URL, routing the action back to your agent.

Configure Callbacks

Set up callbacks using the on_action parameter when publishing:

page = client.publish(
    title="Refund Approval",
    page_type="approval",
    blocks=[...],
    actions=[
        surflet.Action("act_approve", "Approve", style="primary"),
        surflet.Action("act_reject", "Reject", style="danger"),
    ],
    on_action={
        "default_url": "https://your-agent.example.com/webhook/surflet",
        "hmac_secret": "your-secret-key",
        "events": ["action.executed", "approval.chain_completed"],
    },
)

Callback Parameters

ParameterTypeDescription
default_urlstringDefault callback URL
hmac_secretstringHMAC signing secret (optional)
eventsstring[]Event types to subscribe to (optional, defaults to all)
headersobjectCustom request headers (optional)
retryobjectRetry configuration (optional)

Per-Action URL Routing

on_action = {
    "default_url": "https://agent.example.com/webhook/default",
    "action_urls": {
        "act_approve": "https://agent.example.com/webhook/approve",
        "act_reject": "https://agent.example.com/webhook/reject",
    },
}

Event Types

action.executed

A user clicked an action button.

{
  "event": "action.executed",
  "event_id": "evt_abc123",
  "page_id": "pg_xxx",
  "timestamp": "2026-03-23T15:30:00Z",
  "actor": {
    "identity": "[email protected]",
    "auth_method": "magic_link"
  },
  "data": {
    "action_id": "act_approve",
    "action_label": "Approve Refund",
    "comment": null,
    "form_data": null
  },
  "page_state": {
    "previous": "active",
    "current": "completed"
  }
}

approval.step_completed

A step in the approval chain completed.

{
  "event": "approval.step_completed",
  "event_id": "evt_def456",
  "page_id": "pg_xxx",
  "timestamp": "2026-03-23T15:30:00Z",
  "data": {
    "step_id": "step_manager",
    "step_name": "Manager Approval",
    "decision": "approved",
    "decided_by": "[email protected]",
    "next_step": "step_finance"
  }
}

approval.chain_completed

The entire approval chain completed.

{
  "event": "approval.chain_completed",
  "event_id": "evt_ghi789",
  "page_id": "pg_xxx",
  "timestamp": "2026-03-23T16:00:00Z",
  "data": {
    "final_decision": "approved",
    "steps_summary": [
      {"step_id": "step_manager", "decision": "approved"},
      {"step_id": "step_finance", "decision": "approved"}
    ]
  }
}

page.expired

A page expired.

{
  "event": "page.expired",
  "event_id": "evt_jkl012",
  "page_id": "pg_xxx",
  "timestamp": "2026-03-24T00:00:00Z",
  "data": {
    "reason": "timeout",
    "expired_at": "2026-03-24T00:00:00Z"
  }
}

page.viewed

A page was viewed.

{
  "event": "page.viewed",
  "event_id": "evt_mno345",
  "page_id": "pg_xxx",
  "timestamp": "2026-03-23T15:00:00Z",
  "actor": {
    "identity": "[email protected]",
    "auth_method": "magic_link"
  },
  "data": {
    "view_count": 1,
    "user_agent": "Mozilla/5.0 ..."
  }
}

form.submitted

A form was submitted (for form type pages).

{
  "event": "form.submitted",
  "event_id": "evt_pqr678",
  "page_id": "pg_xxx",
  "timestamp": "2026-03-23T15:30:00Z",
  "actor": {
    "identity": "[email protected]"
  },
  "data": {
    "form_data": {
      "amount": 127.50,
      "reason": "Product damaged"
    }
  }
}

Handling Callbacks

Python (FastAPI)

from fastapi import FastAPI, Request, Header, HTTPException
import hmac
import hashlib

app = FastAPI()

HMAC_SECRET = "your-secret-key"

def verify_signature(payload: bytes, signature: str) -> bool:
    expected = hmac.new(
        HMAC_SECRET.encode(),
        payload,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)

@app.post("/webhook/surflet")
async def handle_callback(
    request: Request,
    x_surflet_signature: str = Header(None),
):
    body = await request.body()

    # Verify HMAC signature
    if x_surflet_signature:
        if not verify_signature(body, x_surflet_signature):
            raise HTTPException(status_code=401, detail="Invalid signature")

    event = await request.json()

    match event["event"]:
        case "action.executed":
            action_id = event["data"]["action_id"]
            actor = event["actor"]["identity"]
            comment = event["data"].get("comment")
            handle_action(event["page_id"], action_id, actor, comment)

        case "approval.chain_completed":
            decision = event["data"]["final_decision"]
            handle_chain_completed(event["page_id"], decision)

        case "page.expired":
            handle_expiration(event["page_id"])

    return {"ok": True}

TypeScript (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();
const HMAC_SECRET = 'your-secret-key';

app.post('/webhook/surflet', express.raw({ type: 'application/json' }), (req, res) => {
  // Verify HMAC signature
  const signature = req.headers['x-surflet-signature'] as string;
  if (signature) {
    const expected = `sha256=${crypto
      .createHmac('sha256', HMAC_SECRET)
      .update(req.body)
      .digest('hex')}`;
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
  }

  const event = JSON.parse(req.body.toString());

  switch (event.event) {
    case 'action.executed':
      handleAction(event);
      break;
    case 'approval.chain_completed':
      handleChainCompleted(event);
      break;
  }

  res.json({ ok: true });
});

HMAC Signature Verification

Enabling HMAC verification ensures that callbacks genuinely originate from Surflet, protecting against spoofing attacks.

How signing works:

  1. Surflet signs the request body using your hmac_secret with HMAC-SHA256
  2. The signature is placed in the X-Surflet-Signature header, formatted as sha256=<hex>
  3. Your server computes the signature using the same secret and compares
X-Surflet-Signature: sha256=a1b2c3d4e5f6...

Strongly recommended: always enable HMAC verification in production.

Retry Behavior

When a callback fails, Surflet automatically retries:

AttemptDelay
1st1 minute
2nd5 minutes
3rd30 minutes
4th2 hours
5th12 hours

A delivery is considered failed when:

  • HTTP status code is not 2xx
  • Connection timed out (30 seconds)
  • DNS resolution failed

Custom retry policy:

on_action = {
    "default_url": "https://agent.example.com/webhook",
    "retry": {
        "max_attempts": 3,
        "backoff": "exponential",
        "initial_delay_seconds": 60,
    },
}

Response Format

Your webhook endpoint should return a 2xx status code. The response body is optional, but the following directives are supported:

{
  "ok": true,
  "instructions": {
    "update_page": {
      "add_blocks": [
        {
          "type": "callout",
          "data": {"message": "Refund processed", "style": "success"}
        }
      ]
    }
  }
}

Using instructions, you can update page content directly in the callback response without a separate API call.