Skip to content

Commit c686961

Browse files
plcclaude
andcommitted
Add webhook test endpoint, welcome event opt-out, rate limit docs, agent name prominence
- POST /calendars/:id/webhook/test sends test payload with HMAC-SHA256 signing - POST /calendars accepts welcome_event: false to skip auto-created welcome event - Rate limits documented in /docs and /man with RFC draft-7 header info - Agent name/description marked as "recommended" in docs and quickstart - POST /man prioritizes "Name your agent" as first recommendation for unnamed agents - Fixed rateLimit.js comments to match actual limits (1000/min, 20/hour) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c8d5a00 commit c686961

File tree

6 files changed

+193
-31
lines changed

6 files changed

+193
-31
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77
## [Unreleased]
88

99
### Added
10+
- **Webhook test endpoint**`POST /calendars/:id/webhook/test` sends a test payload to the calendar's configured webhook URL and returns the HTTP status code. Supports HMAC-SHA256 signing via `X-CalDave-Signature` when `webhook_secret` is set.
11+
- **Welcome event opt-out**`POST /calendars` now accepts `welcome_event: false` to skip the auto-created welcome event. Default remains true.
12+
- **Rate limit documentation** — rate limits documented in `/docs` and included in `POST /man` responses. All responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers (RFC draft-7).
13+
- **Agent name/description prominence**`POST /agents` docs and quickstart now recommend including `name` and `description` at creation time. `POST /man` recommends naming your agent as the first step for unnamed agents. New `recommended` badge on params.
1014
- **Personalized recommendations in changelog**`GET /changelog` with auth now includes a `recommendations` array with actionable suggestions based on agent state (e.g. name your agent, create your first calendar, add a description).
1115
- **API changelog endpoint**`GET /changelog` returns a structured list of API changes with dates and docs links. With optional Bearer auth, highlights changes introduced since the agent was created. Designed for agents to poll ~weekly.
1216
- **Agent metadata**`POST /agents` now accepts optional `name` and `description` fields to identify agents. New `GET /agents/me` returns the agent's profile. New `PATCH /agents` updates metadata without changing the API key. Agent name and description are surfaced in `POST /man` context.

src/middleware/rateLimit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const skipInTest = () => process.env.NODE_ENV === 'test';
1414

1515
/**
1616
* General API rate limiter (authenticated routes).
17-
* 200 requests per minute per IP.
17+
* 1000 requests per minute per IP.
1818
*/
1919
const apiLimiter = rateLimit({
2020
windowMs: 60 * 1000,
@@ -27,7 +27,7 @@ const apiLimiter = rateLimit({
2727

2828
/**
2929
* Strict limiter for agent creation (unauthenticated).
30-
* 5 requests per hour per IP.
30+
* 20 requests per hour per IP.
3131
*/
3232
const agentCreationLimiter = rateLimit({
3333
windowMs: 60 * 60 * 1000,

src/routes/calendars.js

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function formatCalendar(cal) {
5454
};
5555
}
5656

57-
const KNOWN_CALENDAR_POST_FIELDS = new Set(['name', 'timezone', 'agentmail_api_key']);
57+
const KNOWN_CALENDAR_POST_FIELDS = new Set(['name', 'timezone', 'agentmail_api_key', 'welcome_event']);
5858
const KNOWN_CALENDAR_PATCH_FIELDS = new Set([
5959
'name', 'timezone', 'webhook_url', 'webhook_secret', 'webhook_offsets', 'agentmail_api_key',
6060
]);
@@ -74,7 +74,7 @@ router.post('/', async (req, res) => {
7474
const unknownErr = checkUnknownFields(req.body, KNOWN_CALENDAR_POST_FIELDS);
7575
if (unknownErr) return res.status(400).json({ error: unknownErr });
7676

77-
const { name, timezone, agentmail_api_key } = req.body;
77+
const { name, timezone, agentmail_api_key, welcome_event } = req.body;
7878

7979
if (!name) {
8080
return res.status(400).json({ error: 'name is required' });
@@ -98,29 +98,34 @@ router.post('/', async (req, res) => {
9898
[id, req.agent.id, name, tz, email, token, inbToken, agentmail_api_key || null]
9999
);
100100

101-
// Create welcome event: "Send Peter feedback" at 9am tomorrow in calendar's timezone
102-
const evtId = eventId();
103-
const tomorrow = new Date();
104-
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
105-
const dateStr = tomorrow.toISOString().slice(0, 10); // YYYY-MM-DD
101+
// Create welcome event unless explicitly opted out
102+
const skipWelcome = welcome_event === false;
103+
if (!skipWelcome) {
104+
const evtId = eventId();
105+
const tomorrow = new Date();
106+
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
107+
const dateStr = tomorrow.toISOString().slice(0, 10); // YYYY-MM-DD
106108

107-
// Use Postgres AT TIME ZONE to convert 9am in the calendar's timezone to UTC
108-
const icalUid = evtId + '@caldave.ai';
109-
await pool.query(
110-
`INSERT INTO events (id, calendar_id, title, description, start_time, end_time)
111-
VALUES ($1, $2, $3, $4,
112-
($5::date + TIME '09:00') AT TIME ZONE $6,
113-
($5::date + TIME '09:30') AT TIME ZONE $6)`,
114-
[
115-
evtId, id,
116-
'Send Peter (founder of CalDave) feedback',
117-
'Email Peter Clark, the founder of CalDave, with feedback on CalDave. Is this product working as expected? Is there anything he should add? What is your purpose as an agent? His email is: peterclark@me.com',
118-
dateStr,
119-
tz,
120-
]
121-
);
109+
// Use Postgres AT TIME ZONE to convert 9am in the calendar's timezone to UTC
110+
await pool.query(
111+
`INSERT INTO events (id, calendar_id, title, description, start_time, end_time)
112+
VALUES ($1, $2, $3, $4,
113+
($5::date + TIME '09:00') AT TIME ZONE $6,
114+
($5::date + TIME '09:30') AT TIME ZONE $6)`,
115+
[
116+
evtId, id,
117+
'Send Peter (founder of CalDave) feedback',
118+
'Email Peter Clark, the founder of CalDave, with feedback on CalDave. Is this product working as expected? Is there anything he should add? What is your purpose as an agent? His email is: peterclark@me.com',
119+
dateStr,
120+
tz,
121+
]
122+
);
123+
}
122124

123125
const inboundUrl = `https://${DOMAIN}/inbound/${inbToken}`;
126+
const message = skipWelcome
127+
? `This calendar can receive invites at ${email}. Forward emails to ${inboundUrl}. Save this information.`
128+
: `This calendar can receive invites at ${email}. Forward emails to ${inboundUrl}. Save this information. To welcome you to CalDave we auto-added an event to your calendar asking for feedback — hope that is okay!`;
124129
res.status(201).json({
125130
calendar_id: id,
126131
name,
@@ -129,7 +134,7 @@ router.post('/', async (req, res) => {
129134
ical_feed_url: `https://${DOMAIN}/feeds/${id}.ics?token=${token}`,
130135
feed_token: token,
131136
inbound_webhook_url: inboundUrl,
132-
message: `This calendar can receive invites at ${email}. Forward emails to ${inboundUrl}. Save this information. To welcome you to CalDave we auto-added an event to your calendar asking for feedback — hope that is okay!`,
137+
message,
133138
});
134139
} catch (err) {
135140
await logError(err, { route: 'POST /calendars', method: 'POST', agent_id: req.agent?.id });
@@ -222,6 +227,68 @@ router.patch('/:id', async (req, res) => {
222227
}
223228
});
224229

230+
/**
231+
* POST /calendars/:id/webhook/test
232+
*/
233+
router.post('/:id/webhook/test', async (req, res) => {
234+
try {
235+
const cal = await getOwnedCalendar(req, res);
236+
if (!cal) return;
237+
238+
if (!cal.webhook_url) {
239+
return res.status(400).json({ error: 'No webhook_url configured on this calendar. Set one with PATCH /calendars/:id.' });
240+
}
241+
242+
const testPayload = {
243+
type: 'test',
244+
calendar_id: cal.id,
245+
calendar_name: cal.name,
246+
timestamp: new Date().toISOString(),
247+
message: 'This is a test webhook from CalDave. If you received this, your webhook is configured correctly.',
248+
};
249+
250+
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'CalDave-Webhook/1.0' };
251+
252+
// Sign with HMAC if webhook_secret is set
253+
if (cal.webhook_secret) {
254+
const crypto = require('crypto');
255+
const body = JSON.stringify(testPayload);
256+
const sig = crypto.createHmac('sha256', cal.webhook_secret).update(body).digest('hex');
257+
headers['X-CalDave-Signature'] = sig;
258+
}
259+
260+
const controller = new AbortController();
261+
const timeout = setTimeout(() => controller.abort(), 10000);
262+
263+
try {
264+
const resp = await fetch(cal.webhook_url, {
265+
method: 'POST',
266+
headers,
267+
body: JSON.stringify(testPayload),
268+
signal: controller.signal,
269+
});
270+
clearTimeout(timeout);
271+
272+
res.json({
273+
success: resp.ok,
274+
status_code: resp.status,
275+
webhook_url: cal.webhook_url,
276+
message: resp.ok ? 'Webhook delivered successfully.' : 'Webhook responded with non-2xx status.',
277+
});
278+
} catch (fetchErr) {
279+
clearTimeout(timeout);
280+
res.json({
281+
success: false,
282+
webhook_url: cal.webhook_url,
283+
message: 'Failed to reach webhook URL: ' + fetchErr.message,
284+
});
285+
}
286+
} catch (err) {
287+
await logError(err, { route: 'POST /calendars/:id/webhook/test', method: 'POST', agent_id: req.agent?.id });
288+
res.status(500).json({ error: 'Failed to test webhook' });
289+
}
290+
});
291+
225292
/**
226293
* DELETE /calendars/:id
227294
*/

src/routes/changelog.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,33 @@ async function buildRecommendations(agent) {
117117
// ---------------------------------------------------------------------------
118118

119119
const CHANGELOG = [
120+
{
121+
date: '2026-02-15',
122+
version: null,
123+
changes: [
124+
{
125+
type: 'feature',
126+
title: 'Webhook test endpoint',
127+
description: 'POST /calendars/:id/webhook/test sends a test payload to your configured webhook URL and returns the HTTP status code. Verifies webhook configuration before real events fire. Supports HMAC-SHA256 signing.',
128+
endpoints: ['POST /calendars/:id/webhook/test'],
129+
docs: BASE + '/docs#post-webhook-test',
130+
},
131+
{
132+
type: 'feature',
133+
title: 'Welcome event opt-out',
134+
description: 'POST /calendars now accepts welcome_event: false to skip the auto-created welcome event. Defaults to true (event is created).',
135+
endpoints: ['POST /calendars'],
136+
docs: BASE + '/docs#post-calendars',
137+
},
138+
{
139+
type: 'improvement',
140+
title: 'Rate limit documentation',
141+
description: 'Rate limits are now documented in /docs and included in POST /man responses. All responses include RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers (RFC draft-7).',
142+
endpoints: ['GET /docs', 'POST /man'],
143+
docs: BASE + '/docs#auth',
144+
},
145+
],
146+
},
120147
{
121148
date: '2026-02-14',
122149
version: null,

src/routes/docs.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ router.get('/', (req, res) => {
5252
.param-desc { color: #94a3b8; }
5353
.param-req { color: #f87171; font-size: 0.75rem; }
5454
.param-opt { color: #64748b; font-size: 0.75rem; }
55+
.param-rec { color: #fbbf24; font-size: 0.75rem; }
5556
.response-note { color: #64748b; font-size: 0.8125rem; font-style: italic; }
5657
.toc { background: #1e293b; border-radius: 12px; padding: 1.5rem; margin-bottom: 2rem; }
5758
.toc h2 { margin-top: 0; border-bottom: none; padding-bottom: 0; }
@@ -94,6 +95,7 @@ router.get('/', (req, res) => {
9495
<li><a href="#get-calendar">GET /calendars/:id</a> — Get calendar</li>
9596
<li><a href="#patch-calendar">PATCH /calendars/:id</a> — Update calendar</li>
9697
<li><a href="#delete-calendar">DELETE /calendars/:id</a> — Delete calendar</li>
98+
<li><a href="#post-webhook-test">POST /calendars/:id/webhook/test</a> — Test webhook</li>
9799
</ul>
98100
<div class="section">Events</div>
99101
<ul>
@@ -130,6 +132,14 @@ router.get('/', (req, res) => {
130132
<p>Exceptions: <code class="inline-code">POST /agents</code> (no auth), <code class="inline-code">GET /feeds</code> (token in query param), and <code class="inline-code">POST /inbound</code> (token in URL path).</p>
131133
<p style="margin-top:1rem; font-size:0.8125rem; color:#64748b;">In curl examples below, <code class="inline-code" style="color:#fbbf24;">YOUR_API_KEY</code>, <code class="inline-code" style="color:#fbbf24;">CAL_ID</code>, <code class="inline-code" style="color:#fbbf24;">EVT_ID</code>, and <code class="inline-code" style="color:#fbbf24;">FEED_TOKEN</code> are placeholders — replace them with your real values.</p>
132134
135+
<h3 style="margin-top:1.5rem;">Rate Limits</h3>
136+
<div class="params" style="margin-bottom:1.5rem;">
137+
<div class="param"><span class="param-name" style="min-width:200px;">API endpoints</span><span class="param-desc">1000 requests / minute per IP</span></div>
138+
<div class="param"><span class="param-name" style="min-width:200px;">POST /agents</span><span class="param-desc">20 requests / hour per IP</span></div>
139+
<div class="param"><span class="param-name" style="min-width:200px;">Inbound webhooks</span><span class="param-desc">60 requests / minute per IP</span></div>
140+
</div>
141+
<p style="font-size:0.8125rem; color:#64748b;">Responses include <code class="inline-code">RateLimit-Limit</code>, <code class="inline-code">RateLimit-Remaining</code>, and <code class="inline-code">RateLimit-Reset</code> headers (RFC draft-7). When exceeded, you receive a 429 response.</p>
142+
133143
<!-- ============================================================ -->
134144
<h2 id="agents">Agents</h2>
135145
@@ -139,11 +149,11 @@ router.get('/', (req, res) => {
139149
<span class="path">/agents</span>
140150
<span class="auth-badge">No auth</span>
141151
</div>
142-
<p class="desc">Create a new agent identity. Returns credentials you must save — the API key is shown once.</p>
152+
<p class="desc">Create a new agent identity. Returns credentials you must save — the API key is shown once. Include <code class="inline-code">name</code> and <code class="inline-code">description</code> to identify your agent — the name appears in outbound email From headers.</p>
143153
<div class="label">Body parameters</div>
144154
<div class="params">
145-
<div class="param"><span class="param-name">name <span class="param-opt">optional</span></span><span class="param-desc">Display name for the agent (max 255 chars). Shown in outbound email From headers.</span></div>
146-
<div class="param"><span class="param-name">description <span class="param-opt">optional</span></span><span class="param-desc">What the agent does (max 1024 chars). Surfaced in POST /man context.</span></div>
155+
<div class="param"><span class="param-name">name <span class="param-rec">recommended</span></span><span class="param-desc">Display name for the agent (max 255 chars). Appears in outbound email From headers (e.g. "My Agent" &lt;cal-xxx@${EMAIL_DOMAIN}&gt;).</span></div>
156+
<div class="param"><span class="param-name">description <span class="param-rec">recommended</span></span><span class="param-desc">What the agent does (max 1024 chars). Surfaced in POST /man personalized context.</span></div>
147157
</div>
148158
<div class="label">Example</div>
149159
<pre><code>curl -s -X POST https://${DOMAIN}/agents \\
@@ -220,6 +230,7 @@ router.get('/', (req, res) => {
220230
<div class="param"><span class="param-name">name <span class="param-req">required</span></span><span class="param-desc">Calendar display name</span></div>
221231
<div class="param"><span class="param-name">timezone <span class="param-opt">optional</span></span><span class="param-desc">IANA timezone (default: UTC)</span></div>
222232
<div class="param"><span class="param-name">agentmail_api_key <span class="param-opt">optional</span></span><span class="param-desc">AgentMail API key for fetching inbound email attachments</span></div>
233+
<div class="param"><span class="param-name">welcome_event <span class="param-opt">optional</span></span><span class="param-desc">Set to <code class="inline-code">false</code> to skip the auto-created welcome event. Defaults to true.</span></div>
223234
</div>
224235
<div class="label">Example</div>
225236
<pre><code>curl -s -X POST https://${DOMAIN}/calendars \\
@@ -298,6 +309,26 @@ router.get('/', (req, res) => {
298309
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
299310
</div>
300311
312+
<div class="endpoint" id="post-webhook-test">
313+
<div class="method-path">
314+
<span class="method post">POST</span>
315+
<span class="path">/calendars/:id/webhook/test</span>
316+
<span class="auth-badge required">Bearer token</span>
317+
</div>
318+
<p class="desc">Send a test payload to the calendar's configured webhook URL. Returns the HTTP status code from the webhook endpoint. Useful for verifying webhook configuration before real events fire.</p>
319+
<div class="label">Example</div>
320+
<pre><code>curl -s -X POST https://${DOMAIN}/calendars/CAL_ID/webhook/test \\
321+
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
322+
<div class="label">Response</div>
323+
<pre><code>{
324+
"success": true,
325+
"status_code": 200,
326+
"webhook_url": "https://example.com/webhook",
327+
"message": "Webhook delivered successfully."
328+
}</code></pre>
329+
<div class="note">The test payload includes <code class="inline-code">type: "test"</code> so your webhook handler can distinguish test pings from real events. If <code class="inline-code">webhook_secret</code> is set, the payload is signed with HMAC-SHA256 via the <code class="inline-code">X-CalDave-Signature</code> header.</div>
330+
</div>
331+
301332
<!-- ============================================================ -->
302333
<h2 id="events">Events</h2>
303334
@@ -616,8 +647,10 @@ Daily standup 2026-02-13 16:00:00Z ...
616647
<div class="endpoint">
617648
<p class="desc">Get up and running in three steps:</p>
618649
<div class="label">1. Create an agent</div>
619-
<pre><code>curl -s -X POST https://${DOMAIN}/agents</code></pre>
620-
<p class="response-note">Save the agent_id and api_key from the response.</p>
650+
<pre><code>curl -s -X POST https://${DOMAIN}/agents \\
651+
-H "Content-Type: application/json" \\
652+
-d '{"name": "My Agent", "description": "What this agent does"}'</code></pre>
653+
<p class="response-note">Save the agent_id and api_key from the response. The name appears in outbound emails.</p>
621654
622655
<div class="label" style="margin-top: 1rem;">2. Create a calendar</div>
623656
<pre><code>curl -s -X POST https://${DOMAIN}/calendars \\

0 commit comments

Comments
 (0)