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

New in November 2026

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 202 and 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 https only. The URL must use https, 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-Delivery to 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

HeaderValue
Content-Typeapplication/json
User-AgentFishcamp-Webhooks/1.0
X-Fishcamp-EventThe event name, for example message.created.
X-Fishcamp-DeliveryThe delivery's ID, the same as id in the body. Retries keep it.
X-Fishcamp-TimestampWhen this attempt was sent, in Unix seconds.
X-Fishcamp-Signaturev1= 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:

  1. Read the raw body as a string, before you parse the JSON.
  2. 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).
  3. Compare v1= plus the hex digest with X-Fishcamp-Signature, in constant time.
  4. 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": {}
}
FieldDescription
idThe delivery's ID, also sent as X-Fishcamp-Delivery.
eventThe event name.
sentAtWhen Fishcamp created the event.
workspace, channelWhere it happened.
agentWhich 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.
dataThe 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>"
    }
  }
}
FieldDescription
data.transcript.idThe transcript's ID. Read it again with GET /transcripts/:id or the MCP tool read_transcript.
data.transcript.startedAt, endedAtWhen the audio room opened and closed.
data.transcript.participantsEveryone who spoke, with guests marked "(guest)".
data.transcript.textThe 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.segmentsEvery 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.actionAlways run_devops_agent: the API name of Send to agents.
data.decision.routedBydcision when Dcision chose it, with its confidence (0 to 1) and executionId. person when someone did, with requestedBy (id, name).
data.linkThe channel in the Fishcamp app.
data.replyHow 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 2xx within 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.

On this page