# Guide to Aimfox Webhooks: Features and Best Practices

Aimfox webhooks report what your LinkedIn outreach is doing as it happens: a connection request is accepted, a lead replies, a campaign ends or a LinkedIn account gets logged out. If your team runs campaigns in Aimfox, webhooks are how that activity reaches your CRM without polling the Aimfox API.

This guide covers how Aimfox webhooks are set up, the events and payload shape, how to authenticate deliveries with the Authentication Header, Aimfox's retry schedule, and the practices that keep an Aimfox receiver reliable.

## What are Aimfox webhooks?

Aimfox webhooks notify your systems "about events as they occur in your Aimfox workspace". You can add several endpoints to a workspace, in the dashboard or through the API, and each endpoint subscribes to one or more event types. Aimfox marks its webhook documentation as beta.

Every delivery is a JSON object with four fields: an event `id`, the `event_type`, the `workspace` and an `event` object whose contents depend on the type.

Aimfox doesn't sign webhooks. The only authentication is an optional **Authentication Header** that you set on the webhook. It "supports Basic Auth or custom headers such as Bearer tokens", and Aimfox sends it with every request for your receiver to compare.

## Aimfox webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Workspace settings > Webhooks**, or the Webhooks API |
| Subscriptions | Several endpoints per workspace, each with one or more event types |
| Format | JSON |
| Event identifier | `event_type` |
| Idempotency key | `id` |
| Authentication | Optional Authentication Header: Basic, or a custom header such as a Bearer token. No signature |
| Retries | Up to 6, at 1 minute, then 5 minutes, 30 minutes, 1 hour, 6 hours and 1 day after the previous attempt |
| Method, timeout and success codes | Not documented |
| Source IPs | Not published |
| Status | Beta |

## Common events

Aimfox documents 17 event types:

| Area | Events | Fires when |
| --- | --- | --- |
| Accounts | `account_logged_in`, `account_logged_out` | A workspace LinkedIn account logs in and is authenticated, or is logged out manually or by session expiry |
| Connections | `new_connection`, `connect`, `accepted` | A new connection is detected on a workspace account however it was made, a campaign sends a connection request, or a campaign's request is accepted |
| Campaign activity | `view`, `message`, `inmail` | A campaign visits a profile, sends a message, or sends an InMail |
| Replies | `reply`, `campaign_reply`, `inmail_reply`, `new_reply` | See below |
| Inbox | `inbox_event` | Any inbox activity on a workspace account. `event.type` says whether it was sent or received |
| Leads | `lead_label_added` | A label is applied to a lead |
| Campaigns | `campaign_created`, `campaign_started`, `campaign_ended` | A campaign is created, starts for the first time, or runs to completion |

The reply events overlap, so pick deliberately:

* `reply` fires once per lead, on their first reply to a campaign message.
* `campaign_reply` fires on every reply to a campaign message.
* `inmail_reply` fires when a lead replies to a campaign InMail.
* `new_reply` fires for a reply to any LinkedIn message on a workspace account, whether or not a campaign sent the original.

Here is Aimfox's `account_logged_in` example:

```json
{
  "id": "b82dce4c-34ef-4d08-875b-bf53a7432661",
  "event_type": "account_logged_in",
  "event": {
    "state": "INACTIVE",
    "default_session_id": "56235b2f-ff29-403f-9844-42334147bd84",
    "urn": "ACoAACjiOMsBxN2eyo8LuQz4xK1D54uGND8jCwg",
    "timestamp": "2024-11-24T21:08:27.966Z",
    "account": {
      "id": 685914315,
      "urn": "ACoAACjiOMsBxN2eyo8LuQz4xK1D54uGND8jCwg",
      "public_identifier": "kengur-kengurovic-1a3865171",
      "first_name": "Kengur",
      "last_name": "Kengurovic",
      "email": "kengur1111@gmail.com",
      "picture_url": "https://cdn.constel.co/linkedin-profile/685914315.jpg"
    }
  },
  "workspace": {
    "id": "47973863-0513-4ee6-910a-c5bc55782ed2",
    "name": "Aimfox Workspace",
    "created_at": 1732541078
  }
}

```

Timestamps come in more than one form. `event.timestamp` is ISO 8601, `workspace.created_at` is Unix seconds, and Aimfox's `campaign_reply` example has a nested `message.created_at` in milliseconds. Parse each field on its own terms.

## Setting up Aimfox webhooks

1. Generate a long random secret, for example with `openssl rand -hex 32`, and decide on the header, such as `Authorization: Bearer <secret>`.
2. In Aimfox, go to **Workspace settings** and open the **Webhooks** tab.
3. Click **Add webhook** and enter a **Name**, your HTTPS endpoint as the **URL**, and the header in **Authentication Header**.
4. Choose the events from the dropdown and click **Save**.

To use Basic Auth instead, generate the `Basic ...` value yourself (Aimfox links to a generator) and paste it as the header value. It's still a fixed value sent with every request.

Through the API, create the webhook with `POST https://api.aimfox.com/api/v3/webhooks` and a normal API key:

```bash
curl -X POST "https://api.aimfox.com/api/v3/webhooks" \
  -H "Authorization: Bearer $AIMFOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Aimfox events",
    "events": ["accepted", "campaign_reply"],
    "url": "https://example.com/webhooks/aimfox",
    "integration": false
  }'

```

Aimfox's create example doesn't include headers, but its edit endpoint, `PATCH /api/v3/webhooks/:webhook_id`, takes a `headers` object such as `{"Authorization": "Bearer 123"}`. Aimfox says `integration` should always be `false`.

**In Hookdeck.** Create a source with the Aimfox type, enter the header name in **API Key Header** and the exact value in **API Key**, including any `Bearer ` or `Basic ` prefix, and use the source URL as the webhook URL. Hookdeck matches the name case-insensitively and the value exactly. The source accepts `POST`. If you don't set an Authentication Header, there's nothing to check, so leave verification off on the source, though setting one is the better choice.

For local development, `hookdeck listen 3000 aimfox --path /webhooks/aimfox` with the [Hookdeck CLI](/docs/cli) gives you a public HTTPS URL, with no account required.

## Securing Aimfox webhooks

Look up your header by name and compare the whole value with the secret in constant time, before parsing the body.

```javascript
const crypto = require('crypto');
const express = require('express');

const app = express();

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/aimfox', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.AIMFOX_WEBHOOK_SECRET; // e.g. "Bearer 9b0f..."
  if (!secret) return res.sendStatus(500); // fail closed when unconfigured

  const name = process.env.AIMFOX_WEBHOOK_HEADER || 'Authorization';
  const received = req.get(name); // case-insensitive lookup
  if (typeof received !== 'string' || !safeEqual(received, secret)) {
    return res.sendStatus(401);
  }

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Store payload.id, then queue payload.event_type / payload.event for a worker.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_aimfox_request(headers) -> bool:
    secret = os.environ.get("AIMFOX_WEBHOOK_SECRET")
    if not secret:
        return False  # fail closed
    name = os.environ.get("AIMFOX_WEBHOOK_HEADER", "Authorization")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A matching header shows the sender knows your secret. It doesn't show the body is unchanged, and a captured request can be replayed later and still pass, because there's no signature and nothing ties the request to a time. Serve the endpoint over HTTPS only, use a long random value, and update the webhook if it may have leaked.

> **Make Aimfox webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Authentication Header on every Aimfox webhook, stores each event durably, and retries delivery to your service on a schedule you control.

## Aimfox webhook limitations and pain points

### Six retries, then the event is lost

**The Problem:** An outage that lasts beyond Aimfox's retry schedule loses events, such as replies your sales team needed to see.

**Why It Happens:** Aimfox retries a failed call "up to 6 times", with the last attempt a day after the one before. After the sixth retry, there's nothing more. Aimfox doesn't document which status codes count as success or how long it waits for a response.

**Workarounds:**

* Respond with a `2xx` quickly and process afterwards.
* After an outage, reconcile from the Aimfox API for the period you were down.

**How Hookdeck Can Help:** Hookdeck accepts each Aimfox webhook as it arrives, so the retry schedule only has to cover the short path to Hookdeck. Hookdeck then [retries](/docs/retries) delivery to your service on your own schedule and lets you replay any event.

### Overlapping reply events

**The Problem:** Subscribing to every reply event creates duplicate notifications for the same message.

**Why It Happens:** A lead's first reply to a campaign message can match `reply`, `campaign_reply` and `new_reply`, and `inbox_event` covers all inbox activity.

**Workarounds:**

* Subscribe to the narrowest event that fits: `campaign_reply` for every campaign reply, or `reply` for first replies only.
* If you need several, deduplicate on the message rather than the delivery.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can drop the event types a destination doesn't need, and [deduplication](/docs/deduplication) can drop repeats based on fields you choose, such as the message URN.

### The header is optional and static

**The Problem:** A webhook saved without an Authentication Header sends events with nothing to check, and one saved with it sends the same value every time.

**Why It Happens:** The field is optional, and Aimfox sends it unchanged. Nothing is computed over the body.

**Workarounds:**

* Always set an Authentication Header with a long random value.
* Keep the endpoint on HTTPS, and rotate the value if it leaks.

**How Hookdeck Can Help:** Hookdeck's Aimfox source compares the header you name with the exact value you enter. Hookdeck answers Aimfox with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination.

### Beta documentation

**The Problem:** Fields or behaviour you rely on may change.

**Why It Happens:** Aimfox labels webhooks as beta, and leaves the method, success codes and timeout undocumented.

**Workarounds:**

* Validate only the fields you use, and ignore unknown fields.
* Answer `200` to event types you don't recognise, so a new type doesn't count as a failure.

**How Hookdeck Can Help:** Every request is stored in Hookdeck with its headers and body, so a change in what Aimfox sends shows up in the request history, and you can replay affected events after updating your handler.

## Best practices

### Deduplicate on `id`

Retries can deliver an event more than once. Store `id` before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Route on `event_type`

Switch on `event_type` and treat `event` as a different shape for each one. For `inbox_event`, check `event.type` to tell sent messages from received ones.

### Watch `account_logged_out`

This event means a workspace account has been logged out of LinkedIn, manually or because its session expired. Alert someone when it arrives so the account can be logged back in quickly.

### Acknowledge fast and process asynchronously

Verify, store, respond with `200`, then update your CRM. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Aimfox sends a JSON object for each subscribed event, with `id`, `event_type`, `workspace` and `event`, and an optional header you set as the only credential. Compare that header in constant time over HTTPS, deduplicate on `id`, choose reply events carefully, and respond quickly, because Aimfox stops after six retries.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the header, accepts each event as it arrives, and keeps it until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Aimfox webhooks reliably in minutes.