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. timestamp records the event envelope time in UTC.

X-LeadPilot-Event contains the event name. X-LeadPilot-Signature contains sha256= followed by the HMAC-SHA256 hexadecimal digest.

These header names retain the historical prefix. Use them exactly as written.

json
{
  "event": "action.executed",
  "timestamp": "2026-09-19T12:00:00Z",
  "data": {
    "action_id": "00000000-0000-0000-0000-000000000001",
    "action_type": "message",
    "lead_id": "00000000-0000-0000-0000-000000000002",
    "campaign_id": "00000000-0000-0000-0000-000000000003"
  }
}

lead.discovered

An enrichment worker promotes an agent-discovered lead.

  • data fields: lead_id, agent_id, agent_name, first_name, last_name, company, headline, profile_url, signal_key.
  • Use the envelope timestamp. The production payload does not include discovered_at.

reply.received

An incoming message matches a lead. LinkedIn, email, and X processing can emit this event.

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

  • data fields: lead_id, campaign_id, conversation_id, first_name, last_name, message_preview.
  • Email events also include channel with the value email.
  • Use the envelope timestamp. The production payload does not include received_at.

message.received

An incoming LinkedIn or X message matches a known lead. Unmatched messages do not produce this customer event.

  • data fields: conversation_id, lead_id, campaign_id, sender_name, sender_provider_id, message, is_first_reply, first_name, last_name.
  • is_first_reply describes the lead's prior reply state. Use the envelope timestamp for event time.

action.executed

The campaign executor completes a scheduled action.

  • data fields: action_id, action_type, lead_id, campaign_id.
  • Use the envelope timestamp. The production payload does not include executed_at.

account.disconnected

Account reconciliation detects a disconnected platform account.

  • data fields: platform_account_id, unipile_account_id, reason.

Other event names

The subscription API accepts lead.approved, campaign.completed, and campaign.paused. The current backend does not emit these events.

Test samples exist for these names. A successful test does not prove live delivery.

The backend can produce reaction.received, but the subscription API rejects that name. It is unavailable for new subscriptions.

Its internal data fields are lead_id, campaign_id, conversation_id, first_name, last_name, and reaction.

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 handle duplicates safely.

The envelope has no unique delivery ID. Choose duplicate detection that fits the event and your application.

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.