> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firsttouch.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> For API operations, fetch https://gateway.firsttouch.ai/api/public/openapi.json for the complete input/output schemas, internal references, and x-mcp-annotations. Endpoint Markdown intentionally omits generated schema fragments. Select /api/public/tools/<tool_name> using the exact underscore-separated name. Send requests to https://gateway.firsttouch.ai; the canonical document currently omits servers. Start at https://docs.firsttouch.com/api-reference/agent-start.md.

# Webhook Technical Reference

> FirstTouch webhook events, JSON payloads, signature verification, retry behaviour, and delivery limits.

This reference describes outgoing FirstTouch webhooks. For destination setup, see [Configure Webhooks](https://docs.firsttouch.com/WebApp/Webhooks/overview.md).

## Event Catalogue

Each destination subscribes to one or more exact event names.

| Event | When it occurs |
| - | - |
| `linkedin.connection.request_sent` | A LinkedIn connection request step finishes, including cases where no new invitation was sent. |
| `linkedin.connection.accepted` | A LinkedIn connection is accepted. |
| `linkedin.message.sent` | A LinkedIn message is successfully sent through FirstTouch. |
| `linkedin.inmail.sent` | A LinkedIn InMail is successfully sent through FirstTouch. |
| `linkedin.message.received` | A new LinkedIn message is received in a conversation tracked by FirstTouch. |
| `linkedin.inmail.received` | A new reply is received in a LinkedIn InMail conversation tracked by FirstTouch. |
| `email.sent` | An email is successfully sent through FirstTouch. This does not establish inbox delivery. |
| `email.received` | A new email is received in a conversation tracked by FirstTouch. |
| `email.bounced` | An email could not be delivered to its recipient. |
| `signal.social.detected` | FirstTouch detects social activity, such as a post, comment, or reaction. |
| `signal.website.detected` | FirstTouch detects a contact or company visiting your website. |

### Event Scope

* A queued, draft, or unconfirmed send is not a successful sent event.
* Received events concern FirstTouch-tracked conversations, not every message in a connected inbox. LinkedIn reply attribution requires an identifiable single external contact and FirstTouch conversation context.
* Initial LinkedIn history imports do not produce live reply events. Configuring a destination does not backfill historical activity.
* InMail and regular messages have distinct subscriptions. A manual FirstTouch reply uses the regular-message send category, even in an InMail-originated conversation.
* Signal events describe detection, not proof that an associated Flow Enrollment started or completed. Company-only website signals may not identify an individual contact.

### Connection Outcomes

For `linkedin.connection.request_sent`, inspect `data.connection.request_submitted` and `data.connection.outcome`. The event name alone does not establish that a new outbound invitation was submitted.

| `outcome` | Meaning |
| - | - |
| `sent` | An outbound request was submitted. |
| `already_connected` | The people were already connected. |
| `outgoing_invitation_pending` | An outgoing invitation was already pending. |
| `already_invited_recently` | A previous invitation was sent recently. |
| `incoming_invitation_accepted` | An incoming invitation was accepted instead. |
| `completed_without_submission` | The step completed without submitting a new request. |

Acceptance events use the same `connection` object, but execution outcome fields can be null when unavailable.

## Request Format

FirstTouch sends an HTTP **POST** with `Content-Type: application/json`. Field names use **snake\_case**. Each request body represents one event, not a batch.

### Common Envelope

| Field | Type | Meaning |
| - | - | - |
| `schema_version` | integer | Payload contract version; currently `1`. |
| `event_id` | string | Stable business event identity; unchanged by retries and resends. |
| `idempotency_key` | string | Logical delivery identity for this destination. Stable across automatic retries and manual resends. |
| `type` | string | Exact event name used for subscription routing. |
| `team_id` | string | FirstTouch Team that owns the event. |
| `occurred_at` | string | Business event time, ISO 8601 UTC. |
| `observed_at` | string | Time FirstTouch recorded the event, ISO 8601 UTC. This can differ from `occurred_at`. |
| `correlation_id` | string or null | Associated operation or enrollment correlation when available. |
| `is_test` | boolean | `true` for a synthetic destination test; `false` for a real event. |
| `data` | object | Business snapshot captured for this event. |

Payloads are recorded when the delivery is created. Subsequent contact changes do not update the recorded payload. Receivers should tolerate additional fields and handle unavailable values rather than assuming every field is populated.

### Business Data

| Object or field | Contents |
| - | - |
| `data.action_id`, `enrollment_id`, `flow_plan_id` | Associated FirstTouch workflow identifiers; null when not linked. |
| `data.contact` | Known external contact: `name`, `first_name`, `last_name`, `email`, `linkedin_url`, `audience_contact_id`, `hubspot_id`, and `hubspot_url`. |
| `data.company` | Known company: `name`, `domain`, `hubspot_id`, and `hubspot_url`. |
| `data.from`, `data.to` | Actual sender and recipient for communications and connection events. The connected-account side can contain `account_id`, `name`, and `email` or `linkedin_url`. The external side has no connected `account_id`. |
| `data.message` | For messages and bounces: `id`, `send_request_id`, `conversation_id`, and `tone`; bounces also include `bounce_type`. |
| `data.connection` | For connection events: `id`, `request_submitted`, and `outcome`. |
| `data.signal` | For signal events: `id` and `type`, plus available detailed signal fields. Signal events do not include communication `from` / `to` objects. |

`audience_contact_id` is supplied only when a unique contact in the Team was matched. HubSpot record IDs are strings, are distinct from FirstTouch IDs, and may be null. A HubSpot record URL requires access to its portal; the presence of the webhook does not establish that CRM sync completed.

`message.tone` may be `positive`, `negative`, `neutral`, or `meeting`, or null when unavailable. A bounce category or message ID may be unavailable; handle `bounce_type: "unknown"` without inferring a hard or soft bounce.

### Content And Optional Fields

Destinations created or saved through the current settings form include available sensitive data. There is no separate content toggle in that form. The payload format also supports destinations with sensitive data disabled; their content fields are omitted rather than guaranteed to appear as null.

When included and available:

* `contact` adds `phone`, `role`, `location`, and `image_url`.
* `company` adds `industry`, `size`, `location`, `site_url`, and `estimated_revenue`.
* `message` adds `subject`, `text`, `content_type`, recipient arrays (`cc`, `bcc`, `additional_recipients`), and `attachments`.
* `connection` adds `note`.
* `signal` adds `context`, `engagement_type`, `reaction_type`, `post_url`, `post_text`, `post_author_linkedin_url`, `visited_url`, `referrer`, `captured_domain`, and `tags`.

Message bodies contain the complete available content, not a fixed-length preview. Content can be plain text or HTML; `content_type` may be unavailable. Treat all content as untrusted data and do not render HTML without sanitisation. Not every message source supplies subjects, recipient lists, content formats, or attachments. Inbound `additional_recipients` are not necessarily CC recipients.

Attachments contain metadata only: `name`, `mime_type`, `size` in bytes, and `url` when available. Binaries are not embedded. Attachment URLs may be null or expire.

Unavailable selected fields can be null; optional arrays can be absent. Missing workflow or contact information is not by itself a delivery failure. The destination's **Testing > Example data > Fields** view provides searchable descriptions, while **JSON** shows examples for that destination's settings.

### Example: Received Email

All values below are synthetic. Real fields depend on the recorded event and the destination's content settings.

```json theme={null}
{
  "schema_version": 1,
  "event_id": "fact_example_email_reply",
  "idempotency_key": "example_delivery",
  "type": "email.received",
  "team_id": "11111111-1111-4111-8111-111111111111",
  "occurred_at": "2026-10-29T10:00:00Z",
  "observed_at": "2026-10-29T10:00:03Z",
  "correlation_id": null,
  "is_test": true,
  "data": {
    "action_id": null,
    "enrollment_id": null,
    "flow_plan_id": null,
    "contact": {
      "name": "Alex Morgan",
      "first_name": "Alex",
      "last_name": "Morgan",
      "email": "alex@example.com",
      "linkedin_url": null,
      "audience_contact_id": null,
      "hubspot_id": null,
      "hubspot_url": null,
      "phone": null,
      "role": null,
      "location": null,
      "image_url": null
    },
    "company": {
      "name": "Example Company",
      "domain": "example.com",
      "hubspot_id": null,
      "hubspot_url": null,
      "industry": null,
      "size": null,
      "location": null,
      "site_url": null,
      "estimated_revenue": null
    },
    "from": { "name": "Alex Morgan", "email": "alex@example.com" },
    "to": {
      "account_id": "example_account",
      "name": "Taylor Smith",
      "email": "taylor@example.org"
    },
    "message": {
      "id": "example_message",
      "send_request_id": null,
      "conversation_id": "example_conversation",
      "tone": "positive",
      "subject": "Re: Let's connect",
      "text": "Thanks, I would like to learn more.",
      "content_type": null,
      "additional_recipients": []
    }
  }
}
```

### Example: Connection Step Without A New Invitation

This is the `data.connection` fragment of a `linkedin.connection.request_sent` payload. It is not a complete request body.

```json theme={null}
{
  "id": "example_connection",
  "request_submitted": false,
  "outcome": "already_connected",
  "note": null
}
```

### Example: Website Signal

This is the `data.signal` fragment of a `signal.website.detected` payload with detailed content included. The complete event also uses the common envelope and contact/company objects.

```json theme={null}
{
  "id": "example_signal",
  "type": "company_website_traffic",
  "context": "Company visited the pricing page",
  "engagement_type": null,
  "reaction_type": null,
  "post_url": null,
  "post_text": null,
  "post_author_linkedin_url": null,
  "visited_url": "https://example.org/pricing",
  "referrer": null,
  "captured_domain": "example.com",
  "tags": null
}
```

## Signature Verification

| Header | Meaning |
| - | - |
| `X-FT-Webhook-Timestamp` | Attempt time as Unix seconds. |
| `X-FT-Webhook-Id` | Logical delivery identity; matches the payload's `idempotency_key`. Stable on retries and resends. |
| `X-FT-Webhook-Attempt-Id` | Unique identity for the individual attempt. |
| `X-FT-Webhook-Signature` | Comma-separated signatures in the form `key_id:v1=lowercase_hex_digest`. Rotation overlap can produce multiple entries. |

The signature is **HMAC-SHA256**, using the complete signing secret (including `whsec_`) as a UTF-8 key. Do not Base64-decode the secret. The signed bytes are the timestamp header, a literal `.`, and the **unaltered raw UTF-8 request body**.

```text theme={null}
HMAC-SHA256(secret, timestamp + "." + raw_body)
```

Verify before trusting or processing the payload. Parsing and reserialising JSON, changing whitespace, or normalising line endings changes the signed bytes.

The Node.js example below accepts any matching signature for a trusted active secret. Its five-minute timestamp tolerance is a **receiver-side recommendation**, not a FirstTouch delivery expiry. Keep the receiver's clock synchronised. Each retry or resend is signed with its current attempt timestamp.

```javascript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyFirstTouchWebhook({
  rawBody,
  timestampHeader,
  signatureHeader,
  secrets,
  nowSeconds = Math.floor(Date.now() / 1000),
  toleranceSeconds = 300,
}) {
  if (!Buffer.isBuffer(rawBody)) return false;
  if (typeof timestampHeader !== 'string' || !/^\d+$/.test(timestampHeader)) return false;
  if (typeof signatureHeader !== 'string') return false;

  const timestamp = Number(timestampHeader);
  if (!Number.isSafeInteger(timestamp)) return false;
  if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;

  const digests = signatureHeader.split(',').flatMap((entry) => {
    const match = /^[^:]+:v1=([a-f0-9]{64})$/.exec(entry.trim());
    return match ? [Buffer.from(match[1], 'hex')] : [];
  });

  return secrets.some((secret) => {
    const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
      .update(timestampHeader + '.')
      .update(rawBody)
      .digest();
    return digests.some((digest) => timingSafeEqual(expected, digest));
  });
}
```

Pass the raw request buffer, the two signature-related headers, and secrets loaded from secure server-side configuration. During rotation, accept the old and new secret until the receiver update is complete and the old secret expires. For operational steps, see [Change A Signing Secret](https://docs.firsttouch.com/WebApp/Webhooks/overview.md#change-a-signing-secret).

### Duplicate Handling

Delivery can be repeated; exactly-once processing is not guaranteed. A receiver may have accepted an event even if FirstTouch did not receive its response.

* Deduplicate by `idempotency_key` or `X-FT-Webhook-Id`, not by attempt ID or the delivery history row ID of a resend.
* Record the deduplication identity durably alongside your processing or queue operation.
* Return `2xx` for an already-accepted duplicate rather than repeating its side effects.
* Acknowledge promptly after durable acceptance; perform slow business processing asynchronously.
* Do not assume business events arrive in occurrence order. A retry can arrive after a newer event.

## Retries And Resends

Any HTTP status from **200 through 299** completes a delivery successfully. Other statuses, including `3xx`, `4xx`, `429`, and `5xx`, and attempts without a response are retried within the attempt budget. Redirects are not followed. The retry schedule is not adjusted from the receiver's `Retry-After` header.

There are **eight total attempts**: the initial attempt and up to seven retries. The nominal gaps **after each unsuccessful attempt**, not cumulative times from creation, are:

| After attempt | Delay before next attempt |
| - | - |
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 4 hours |
| 6 | 7 hours |
| 7 | 10 hours 24 minutes |

Each gap varies by ±5%. Without other delays, the last attempt is approximately 24 hours after the first. Scheduling, backlog, and timeout protection can delay delivery further; this is not a delivery-time guarantee. After eight unsuccessful attempts, the delivery becomes `failed`.

Five consecutive timeouts without an HTTP response pause sending to that destination for **one hour**. Receiving an HTTP response resets the timeout counter. The pause does not turn off the destination.

**Resend** creates a fresh delivery execution with its own attempt history and retry budget. It preserves the original payload bytes, `event_id`, `is_test`, and `idempotency_key`, and links to the original delivery. It uses the destination's current URL and active signing secrets. A receiver that has already processed the identity should acknowledge the resend without duplicating work.

A delivery can be resent only within the 30-day history window, after it is no longer pending or processing, and while its destination exists, is enabled, and has an active signing secret. Resend is available for eligible completed deliveries, not only failed ones.

## Statuses And Limits

| Status | Meaning |
| - | - |
| `pending` | Waiting for its first or next attempt; it may be backing off after a failure. |
| `processing` | A delivery attempt is in progress. |
| `delivered` | The receiver returned `2xx`; downstream business processing is not confirmed. |
| `failed` | The attempt budget was exhausted. |
| `canceled` | Sending was canceled, for example when the destination was turned off or deleted. |

| Limit or constraint | Behaviour |
| - | - |
| Destinations | Up to 20 non-deleted destinations per Team, including turned-off destinations. |
| Destination name | 1–100 characters. |
| Webhook URL | Up to 2,048 characters; public HTTPS DNS hostname on port 443, without embedded credentials or a fragment. |
| Network access | Localhost, private/special-use addresses, and IP-literal URLs are rejected. DNS must resolve only to public addresses. |
| Request timeout | Five seconds. Return response headers promptly; a received `2xx` can still count as delivered if reading its body later fails. |
| History | Delivery payloads and attempts are available for 30 days from delivery creation. |
| Response diagnostics | Up to 4,096 response bytes are read for display before sanitisation; text may be shortened, redacted, or omitted. Response headers are not shown. |
| Signing-secret overlap | Existing secrets remain valid for up to one hour after normal rotation. At most five active overlapping keys are allowed. |

There are no destination controls for custom headers, per-destination rate limits, or segment filters in the current settings form. If your receiving app requires additional authentication headers, use a receiver or adapter that can validate FirstTouch signatures and perform the authenticated downstream request.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.