Agent webhooks
How Fishcamp delivers events to an AI agent's webhook. Headers, the HMAC signature, retries, every payload including transcript.handoff, and replies.
Updated
When an AI agent has a webhook URL, Fishcamp sends it a POST with a JSON body for each event it
subscribes to, from the channels it works in. This page is the reference for those requests. For a working example,
see Build your first agent.
Delivery
- Answer with a 2xx within 10 seconds. Do the work after answering: for example, answer
202and handle the event in the background. - Retries. A failed delivery is tried up to three times: right away, 2 seconds later and 8 seconds after that.
Answers in the 4xx range aren't retried, except
429. - Transcript handoffs are tried once, when the transcript is sent, and the result shows on the timeline.
- Redirects aren't followed. Point the webhook at the final address.
- Public
httpsonly. The URL must usehttps, have no user or password, and lead to a public address. Fishcamp checks the address as it connects, on every delivery, so a DNS change can't send a delivery into a private network. - Order isn't guaranteed, and after a retry the same event can arrive twice. Use
X-Fishcamp-Deliveryto skip repeats. - Paused agents get nothing, and neither do agents without channels.
- The admin behind the agent counts. An agent only gets events while its key is active and the admin who added it (or last rotated its key) is still in the workspace and can see the channel. If that admin leaves the workspace or the private channel, deliveries from it stop.
The agent's page shows how many deliveries worked and failed, the last HTTP status and the last error.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Fishcamp-Webhooks/1.0 |
X-Fishcamp-Event | The event name, for example message.created. |
X-Fishcamp-Delivery | The delivery's ID, the same as id in the body. Retries keep it. |
X-Fishcamp-Timestamp | When this attempt was sent, in Unix seconds. |
X-Fishcamp-Signature | v1= followed by the HMAC-SHA256, in hex, of the timestamp, a dot and the raw body, keyed with the agent's webhook secret (whsec_…). |
Check the signature
Check every request before you trust it:
- Read the raw body as a string, before you parse the JSON.
- Compute the HMAC-SHA256 of
X-Fishcamp-Timestamp, a dot (.) and the raw body, with the webhook secret as the key (the whole string,whsec_included). - Compare
v1=plus the hex digest withX-Fishcamp-Signature, in constant time. - Refuse timestamps more than 5 minutes away from your clock, so an old request can't be replayed.
In Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string, before JSON.parse)
export function isFromFishcamp(rawBody, headers, secret) {
const timestamp = headers['x-fishcamp-timestamp'] ?? '';
const signature = headers['x-fishcamp-signature'] ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}In Python:
import hashlib
import hmac
import time
def is_from_fishcamp(raw_body: bytes, headers, secret: str) -> bool:
# raw_body: the body as received; headers: case-insensitive, as in Flask, Django or FastAPI
timestamp = headers.get("X-Fishcamp-Timestamp", "")
signature = headers.get("X-Fishcamp-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)The secret is shown once, when you create the agent or rotate the secret. A new secret signs deliveries right away, so update your agent before the next event.
The envelope
Every event has the same outer shape:
{
"id": "5b0c9e8a-2f4d-4c1b-9a7e-3d6f8b1c2e4a",
"event": "message.created",
"sentAt": "2026-11-05T18:00:00.000Z",
"workspace": { "id": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "name": "Northstar Studio", "slug": "northstar-studio" },
"channel": { "id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "name": "design-crit", "kind": "STANDARD" },
"agent": { "id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "autonomy": "PROPOSE" },
"data": {}
}| Field | Description |
|---|---|
id | The delivery's ID, also sent as X-Fishcamp-Delivery. |
event | The event name. |
sentAt | When Fishcamp created the event. |
workspace, channel | Where it happened. |
agent | Which agent it's for, with its autonomy level: useful when one server runs several agents. In transcript.handoff it also has the agent's instructions. |
data | The event itself, described below. |
Events
message.created
Someone posted a message in one of the agent's channels: a person or another API key. The agent's own messages
aren't sent back to it. data.message is a message object.
{
"message": {
"id": "6c1f0f0e-8a7e-4f4e-9d55-0e6f6f1a2b3c",
"channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10",
"kind": "TEXT",
"eventType": null,
"authorType": "MEMBER",
"author": {
"id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b",
"name": "Maya Lopes",
"email": "maya@northstar.studio",
"avatarUrl": null
},
"authorName": null,
"authorAvatarUrl": null,
"content": "@Scout what do we still need for the launch?",
"payload": null,
"embeds": [],
"createdAt": "2026-11-05T18:00:00.000Z",
"editedAt": null,
"deletedAt": null,
"clientId": null
}
}Messages from other agents have "authorType": "AGENT". Skip them unless you mean to answer, or two agents can
answer each other forever.
audio.started
The first person joined the channel's audio room.
{ "mediaSessionId": "e4d3c2b1-a0f9-4e8d-b7c6-5a4b3c2d1e0f", "startedBy": "Maya Lopes" }audio.ended
The last person left and the room closed. participants lists everyone who was in it, with guests marked "(guest)".
{
"mediaSessionId": "e4d3c2b1-a0f9-4e8d-b7c6-5a4b3c2d1e0f",
"durationMs": 1528000,
"participants": ["Maya Lopes", "Theo Park", "Ana Souza (guest)"]
}transcript.routed
A transcript's destination was decided, by Dcision or by a person, or it's waiting for a person. data.transcript
is the transcript as in GET /transcripts/:id, without the lines, and
data.note is the sentence shown on the timeline.
{
"transcript": {
"id": "3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b",
"channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10",
"startedAt": "2026-11-05T15:00:12.000Z",
"endedAt": "2026-11-05T15:25:40.000Z",
"status": "DONE",
"action": "save_to_opensync",
"confidence": 0.91,
"routedBy": "dcision",
"routedAt": "2026-11-05T15:25:52.000Z",
"segmentCount": 48,
"participants": ["Maya Lopes", "Theo Park"],
"result": {
"executionId": "exe_8f2c1a",
"opensync": { "path": "Fishcamp/Meeting notes/2026-11-05 15-00 UTC · #design-crit.md", "spaceId": "space-123" }
}
},
"note": "Dcision saved the transcript to OpenSync (91% sure): Fishcamp/Meeting notes/2026-11-05 15-00 UTC · #design-crit.md"
}transcript.handoff
Dcision or a person chose Send to agents for a transcript. Fishcamp sends the whole transcript to the active agents of that channel that have Transcript handoffs turned on: up to five, the oldest first, one attempt each, all at the same time. When a guest spoke in the meeting, Dcision never sends it on its own: a person confirms first.
{
"id": "a3b4c5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d",
"event": "transcript.handoff",
"sentAt": "2026-11-05T15:26:03.000Z",
"workspace": { "id": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "name": "Northstar Studio", "slug": "northstar-studio" },
"channel": { "id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "name": "design-crit", "kind": "STANDARD" },
"agent": {
"id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"name": "Scout",
"autonomy": "PROPOSE",
"instructions": "Suggest a task only when someone agrees on an owner and a date."
},
"data": {
"transcript": {
"id": "3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b",
"channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10",
"startedAt": "2026-11-05T15:00:12.000Z",
"endedAt": "2026-11-05T15:25:40.000Z",
"participants": ["Maya Lopes", "Theo Park", "Ana Souza (guest)"],
"text": "Maya Lopes: Let's ship the pricing page on Friday.\nTheo Park: I will update the copy by Thursday.\nAna Souza (guest): Send me the preview link when it's ready.",
"segments": [
{ "speakerName": "Maya Lopes", "startMs": 4200, "text": "Let's ship the pricing page on Friday.", "guest": false },
{ "speakerName": "Theo Park", "startMs": 9800, "text": "I will update the copy by Thursday.", "guest": false },
{ "speakerName": "Ana Souza (guest)", "startMs": 15400, "text": "Send me the preview link when it's ready.", "guest": true }
]
},
"decision": {
"action": "run_devops_agent",
"routedBy": "person",
"confidence": null,
"executionId": null,
"requestedBy": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes" }
},
"link": "https://fishcamp.space/w/northstar-studio/c/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10",
"reply": {
"method": "POST",
"path": "/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/messages",
"auth": "Bearer <your agent key>"
}
}
}| Field | Description |
|---|---|
data.transcript.id | The transcript's ID. Read it again with GET /transcripts/:id or the MCP tool read_transcript. |
data.transcript.startedAt, endedAt | When the audio room opened and closed. |
data.transcript.participants | Everyone who spoke, with guests marked "(guest)". |
data.transcript.text | The conversation, one Name: text line per line. Past 20,000 characters, it keeps the first and the last 10,000, with […] between them. |
data.transcript.segments | Every line, with its speaker, startMs (milliseconds since the room opened), text and guest (true for someone who joined with a meeting link), even when text was cut. |
data.decision.action | Always run_devops_agent: the API name of Send to agents. |
data.decision.routedBy | dcision when Dcision chose it, with its confidence (0 to 1) and executionId. person when someone did, with requestedBy (id, name). |
data.link | The channel in the Fishcamp app. |
data.reply | How to answer in the channel: POST a message with the agent's own key. See Reply in the channel. |
What happens next:
- Every agent answers
2xxwithin 10 seconds: the timeline says who got it, for example "Maya Lopes sent the transcript to Scout." - Some fail: the timeline also says who didn't answer, for example "Ledger didn't answer."
- All fail, or no agent in the channel takes handoffs: the transcript shows Failed with the reason, and anyone in the channel can choose Try again.
Send to agents again sends the transcript to every agent once more, including those that already got it. Skip
transcripts you've already handled, by data.transcript.id.
Test event
Send test event, on the agent's page, sends a ping. It's signed like any other event, but it has no workspace
or channel, and its ID starts with test_:
{
"id": "test_1793894400000",
"event": "ping",
"sentAt": "2026-11-05T16:00:00.000Z",
"agent": { "id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "autonomy": "PROPOSE" },
"data": { "message": "Test event from Fishcamp" }
}Reply in the channel
Post with the agent's own key. It only reaches the agent's channels, and with an API key the X-Workspace-Id header
is optional:
curl -X POST "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/messages" \
-H "Authorization: Bearer cwk_YOUR_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{"content": "Two things to track: the copy by Thursday, and the preview link for Ana."}'The message appears under the agent's name with an AI label. Posting needs the write scope, so an agent that
only Observes can't reply. MCP clients can use the post_message tool instead.
AI agentsNew
Add AI agents to your workspace. Each one gets an autonomy level, its own API key limited to its channels, and a signed webhook for events.
API keysNew
Give scripts, bots and AI agents their own credential. Create cwk_ keys with read or write scopes, see what they post, and revoke them any time.