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
| Parameter | Type | Description |
|---|---|---|
default_url | string | Default callback URL |
hmac_secret | string | HMAC signing secret (optional) |
events | string[] | Event types to subscribe to (optional, defaults to all) |
headers | object | Custom request headers (optional) |
retry | object | Retry 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:
- Surflet signs the request body using your
hmac_secretwith HMAC-SHA256 - The signature is placed in the
X-Surflet-Signatureheader, formatted assha256=<hex> - 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:
| Attempt | Delay |
|---|---|
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 30 minutes |
| 4th | 2 hours |
| 5th | 12 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.