Developer guide
Outbound webhooks
Receive signed workspace events and verify their exact delivery behavior.
Create a subscription
Create a subscription through POST /api/webhooks/outbound. Use a public HTTPS URL and an events array.
Private network destinations are blocked. Optionally associate the subscription with an integration that you own.
Save the secret from the creation response. List and update responses mask the secret.
The update route changes URL, events, and active status. To replace a secret, create a replacement subscription.
{
"url": "https://example.com/webhooks/funkel",
"events": ["lead.discovered", "reply.received"]
}Event envelope
Delivery uses POST with a JSON body. id is a unique event ID: use it to drop duplicates. timestamp records the event time in UTC.
X-Funkel-Event contains the event name. X-Funkel-Event-Id repeats the event id. X-Funkel-Signature contains sha256= followed by the HMAC-SHA256 hexadecimal digest.
Fields are only added over time, never renamed or removed. Ignore fields you do not use.
{
"id": "6f1c2b9e-0c55-4a8e-9d43-2d1f0d6a7e11",
"event": "action.executed",
"timestamp": "2026-10-18T12:00:00Z",
"data": {
"action_id": "00000000-0000-0000-0000-000000000040",
"action_type": "message",
"lead_id": "00000000-0000-0000-0000-000000000001",
"contact_id": "00000000-0000-0000-0000-000000000003",
"campaign_id": "00000000-0000-0000-0000-000000000020",
"campaign_name": "Sample Campaign",
"product_id": "00000000-0000-0000-0000-000000000022",
"product_name": "Sample Product",
"platform": "linkedin",
"channel": "linkedin",
"message": "Hi Jane, saw your post about onboarding handoffs."
}
}Lead IDs
contact_id is the same person across every event. Use it to link a found lead to its later replies and actions.
On lead.discovered and lead.approved, lead_id and discovered_lead_id are the found-lead ID. On all other events, lead_id is the campaign lead ID.
lead.discovered
An agent finds a lead and saves it for review or approves it automatically.
- data fields: lead_id, discovered_lead_id, contact_id, agent_id, agent_name, first_name, last_name, company, headline, profile_url, signal_key, status (review or approved), match_score, platform, discovered_at.
- Context fields, when the event has them: campaign_id, campaign_name, product_id, product_name, list_id, agent_id.
lead.approved
A found lead is approved: by you in the app, through the API or MCP, or automatically by the agent.
- data fields: the lead.discovered fields, with status approved, plus approved_at.
- Context fields, when the event has them: campaign_id, campaign_name, product_id, product_name, list_id, agent_id.
message.received
An incoming LinkedIn or X message matches a known lead. Unmatched messages do not produce this event.
- data fields: conversation_id, sender_name, sender_provider_id, message, is_first_reply, channel, message_id (when available), received_at.
- is_first_reply describes the lead's prior reply state.
- Lead fields: lead_id, contact_id, first_name, last_name, company, headline, profile_url, email (when known), sender_account_id.
- Context fields, when the event has them: campaign_id, campaign_name, product_id, product_name, list_id, agent_id.
reaction.received
A lead reacts to one of your messages.
- data fields: conversation_id, reaction, channel, message_id (when available), received_at.
- Lead fields: lead_id, contact_id, first_name, last_name, company, headline, profile_url, email (when known), sender_account_id.
- Context fields, when the event has them: campaign_id, campaign_name, product_id, product_name, list_id, agent_id.
action.executed
The campaign sends an invite, message, or other scheduled action.
- data fields: action_id, action_type, platform, channel, workflow_step_id, message (the text sent, when there is one), executed_at.
- Lead fields: lead_id, contact_id, first_name, last_name, company, headline, profile_url, email (when known), sender_account_id.
- Context fields, when the event has them: campaign_id, campaign_name, product_id, product_name, list_id, agent_id.
campaign.paused
A campaign moves from active to paused.
- data fields: campaign_id, reason, paused_at.
- reason values: user (paused in the app, API or MCP), account_disconnected (its sender account disconnected), sender_removed (a team member who sent for it left), billing (payment failed or the plan ended), beta_ended.
- Context fields, when the event has them: campaign_id, campaign_name, product_id, product_name, list_id, agent_id.
account.disconnected
A connected sender account disconnects.
- data fields: platform_account_id, platform, account_name, reason.
Verify signatures
Calculate HMAC-SHA256 over the exact raw request body with your subscription secret. Compare signatures before parsing or processing.
Do not reformat the JSON before verification. Reject missing or malformed signatures.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(rawBody, signature, secret) {
if (typeof signature !== "string" || !/^sha256=[0-9a-f]{64}$/.test(signature)) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const receivedBytes = Buffer.from(signature, "utf8");
const expectedBytes = Buffer.from(expected, "utf8");
return receivedBytes.length === expectedBytes.length &&
timingSafeEqual(receivedBytes, expectedBytes);
}Delivery and retries
The worker attempts each active matching subscription with a ten-second timeout.
It logs destination errors and HTTP responses. It does not retry individual failed deliveries or non-2xx responses.
Delivery has no exactly-once guarantee. Store incoming events before processing, and use the envelope id to drop duplicates.
Delivery order is not guaranteed. Use REST reads to reconcile current state when needed.
Demo workspaces do not send external webhooks.
Test your receiver
Use POST /api/webhooks/outbound/{id}/test for a signed request with the saved subscription secret.
Add ?event=lead.discovered for a representative sample. Without event, the route sends a test event.
Inspect success and status_code in the response. A successful API response alone does not confirm receiver success.
GET /api/webhooks/outbound/sample-payload returns a sample. Samples can include fields absent from production events.
POST /api/webhooks/outbound/test-url checks an unsaved destination. It signs with a temporary secret returned in the test response.