Funkel AIDevelopersDashboard ↗

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.

json
{
  "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.

json
{
  "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.

reply.received

A lead replies on LinkedIn, by email, or on X.

Do not assume one event per lead. The emit conditions differ between channels.

  • data fields: conversation_id, message_preview, channel (linkedin, email or x), 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.

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.

javascript
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.