Gareth Wilson Gareth Wilson

Guide to Unipile Webhooks: Features and Best Practices

Published


Unipile webhooks tell your application when something happens in the messaging and email accounts your users have connected: a LinkedIn or WhatsApp message arrives, an email is sent or received, a tracked email is opened, a LinkedIn invitation is accepted, or an account needs reconnecting. If you build outreach, CRM or inbox tools on Unipile's unified API, webhooks are how those changes reach you without polling each provider.

This guide covers how Unipile webhooks are created, how to authenticate deliveries that Unipile does not sign, the payloads for each kind of webhook, how Unipile retries, and the best practices for production.

What are Unipile webhooks?

Unipile is a single API over LinkedIn, WhatsApp, Instagram, Messenger, Telegram, X, Google, Microsoft and IMAP accounts. Each customer gets their own API host, which Unipile calls a DSN.

A Unipile webhook has a source, which decides what kind of data it sends: messaging, email, email_tracking, users or account_status. You create one webhook per source, each with its own URL, and Unipile sends an HTTP POST to it when an event happens.

Unipile does not sign webhooks. There is no signature header, HMAC or signing secret. The only way to authenticate a delivery is to add a secret header of your own to the webhook when you create it.

Unipile webhook features

FeatureDetails
ConfigurationUnipile dashboard, or POST https://{YOUR_DSN}/api/v1/webhooks
SubscriptionOne source per webhook, with an optional events list
AuthenticationNo signature. Add a static header through the headers array
Content typeDashboard webhooks send JSON with a JSON content type. API webhooks send no content type unless you add one
Event fieldevent in the body, except account status bodies
Response windowA 200 within 30 seconds
RetriesFive retry attempts, with an increasing delay between them
Delivery IDNone
Source IPsNot published

Common events

sourceEventsFires when
messagingmessage_received (default), message_read, message_reaction, message_edited, message_deleted, message_deliveredA chat message is received or sent, read, reacted to, edited, deleted or delivered
emailmail_received (default), mail_sent, mail_movedAn email is received, sent or moved
email_trackingmail_opened (default), mail_link_clickedA tracked email you sent is opened, or a link in it is clicked
usersnew_relationA new LinkedIn connection appears, including accepted invitations
account_statusOK, CREDENTIALS, ERROR, STOPPED, CONNECTING, DELETED, CREATION_SUCCESS, RECONNECTED, SYNC_SUCCESS and othersA connected account's status changes

Messages you send are delivered as message_received too, whether they were sent from the API or from another device. To tell them apart, compare account_info.user_id with sender.attendee_provider_id. Email bodies have an origin field set to unipile or external.

new_relation is not real time. Unipile polls the LinkedIn relation list at random intervals, so the event can arrive up to 8 hours after the connection is accepted.

Most bodies are a flat object with an event field. Here is Unipile's new_relation example:

{
  "event": "new_relation",
  "account_id": "SDF4tGaPSPSzNe1D1xsOs",
  "account_type": "LINKEDIN",
  "webhook_name": "",
  "user_full_name": "Satya Nadella",
  "user_provider_id": "ACoAAAEkwwAB9KEc2TrQgOLEQ-vzRyZeCDyc6DQ",
  "user_public_identifier": "satyanadella",
  "user_profile_url": "https://www.linkedin.com/in/satyanadella/",
  "user_picture_url": "https://media.licdn.com/dms/image/..."
}

Messaging bodies carry account_id, account_type, account_info, chat_id, message_id, message, timestamp, sender, attendees and attachments, plus reaction and reaction_sender on message_reaction. Email bodies carry email_id, from_attendee, to_attendees, subject, body, folders and tracking_id. Tracking bodies carry event_id, tracking_id, email_id, ip, user_agent and, for clicks, url.

Account status bodies are different. They have no event field at all:

{
  "AccountStatus": {
    "account_id": "h_EKCy2lRLef5NzHp0iw4A",
    "account_type": "LINKEDIN",
    "message": "CREDENTIALS"
  }
}

CREDENTIALS means the account's sync stopped because its credentials are invalid or missing, for example because the user changed their password or revoked access. While an account is not OK, Unipile stops sending new-message webhooks for it. By default, an account_status webhook covers the usual statuses except CONNECTING, OK and STOPPED, so add those to events if you need them.

Setting up Unipile webhooks

Generate a secret first, for example with openssl rand -hex 32, and store it as UNIPILE_WEBHOOK_SECRET. Then create a webhook through the API, adding the secret as a header. Add a Content-Type header at the same time, because API-created webhooks don't send one:

curl --request POST \
  --url "https://$UNIPILE_DSN/api/v1/webhooks" \
  --header "X-API-KEY: $UNIPILE_API_KEY" \
  --header "content-type: application/json" \
  --data '{
    "request_url": "https://example.com/webhooks/unipile",
    "source": "messaging",
    "name": "Messages",
    "headers": [
      { "key": "Content-Type", "value": "application/json" },
      { "key": "Unipile-Auth", "value": "YOUR_GENERATED_SECRET" }
    ]
  }'

Unipile-Auth is the name Unipile uses in its example. The name is up to you. Repeat the call for each source you need, with the same or a different URL.

You can also create webhooks in the Unipile dashboard. Unipile's docs show custom headers only for API-created webhooks, so if the dashboard form has no place for a header, create the webhook through the API instead.

For local development, hookdeck listen 3000 unipile --path /webhooks/unipile gives you a public HTTPS URL to use as request_url.

Securing Unipile webhooks

Nothing is signed, so check the header you configured. Compare it in constant time, fail closed if the secret is not configured, and check the type and length first, because crypto.timingSafeEqual throws on buffers of different lengths. Since there's no body signature, the order of parsing and checking doesn't matter. Reading the raw body is still useful, because API-created webhooks arrive without a JSON content type.

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

const SECRET_HEADER = 'unipile-auth'; // the name you used in the headers array

function verifyUnipileSecret(provided, expected) {
  if (typeof provided !== 'string' || !expected) return false;
  const a = Buffer.from(provided, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/unipile', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.UNIPILE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyUnipileSecret(req.get(SECRET_HEADER), secret)) {
    return res.sendStatus(401);
  }

  const body = JSON.parse(req.body.toString('utf8'));

  if (body.AccountStatus) {
    // Account status: body.AccountStatus.message is OK, CREDENTIALS, ...
  } else {
    // Everything else: route on body.event
  }

  res.sendStatus(200);
});

The same check in Python:

import hmac
import os


def verify_unipile_secret(provided, expected) -> bool:
    if not isinstance(provided, str) or not expected:
        return False
    return hmac.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))


# In FastAPI:
# verify_unipile_secret(request.headers.get("unipile-auth"), os.environ.get("UNIPILE_WEBHOOK_SECRET"))

This proves the caller knows your secret. It does not prove the body is unchanged, and a captured request can be replayed, because the header is the same on every delivery.

Make Unipile webhooks production-ready. Hookdeck Event Gateway checks your secret header before anything reaches your code, answers Unipile inside its 30 second window, and durably queues every message, email and account event.

Unipile webhook limitations and pain points

No signature, only a header you add

The Problem: A webhook created without a secret header accepts anything sent to its URL, and even with one, a single leaked request exposes the secret.

Why It Happens: Unipile issues no signing secret. Its only documented authentication is a header you put in the webhook's headers array, which it repeats verbatim on every delivery.

Workarounds:

  • Add a long random secret header to every webhook, and reject requests without it.
  • Serve the endpoint over HTTPS only, and never log the header.
  • To rotate, accept both the old and new values while you re-create the webhooks.

How Hookdeck Can Help: Hookdeck's Unipile source type checks a header name and value you choose, for example Unipile-Auth and your secret, and stops requests that fail before they reach your service. Hookdeck compares the whole header value, so enter it exactly as you set it in Unipile.

Two payload shapes

The Problem: A handler that switches on event silently ignores every account status change.

Why It Happens: Account status webhooks send {"AccountStatus": {...}} with the status in AccountStatus.message, while every other source sends a flat object with event.

Workarounds:

  • Check for AccountStatus first, then fall back to event.
  • Use separate URLs for account_status and the other sources if that keeps your code simpler.

How Hookdeck Can Help: Hookdeck filters can route on AccountStatus.message or on event, so account health and message traffic can go to different services from one source.

A short retry window

The Problem: Messages received during a deploy or an outage are lost.

Why It Happens: Unipile expects a 200 within 30 seconds. Anything else gets five retry attempts with increasing delays. Unipile doesn't publish the delays, so you can't tell how long an outage it covers.

Workarounds:

  • Acknowledge first, then process.
  • After an outage, re-read recent chats and emails through the API for the affected accounts.

How Hookdeck Can Help: Hookdeck accepts each delivery and stores it, then retries delivery to your service on a schedule you set, with replay for anything still failing. Unipile's five attempts no longer bound how long you can be down.

Disconnected accounts go quiet

The Problem: Messages stop arriving for one user, and no error appears anywhere in your app.

Why It Happens: When an account's status is anything other than OK, Unipile stops sending new-message webhooks for it. LinkedIn and Instagram sessions also expire after a year, and providers can restrict accounts that make too many sensitive requests.

Workarounds:

  • Subscribe to account_status and prompt the user to reconnect on CREDENTIALS.
  • After RECONNECTED, expect the messages that arrived while the account was disconnected.

How Hookdeck Can Help: Route CREDENTIALS and ERROR statuses to an alerting destination with a Hookdeck filter, so a dead account becomes a notification rather than silence.

Best practices

Always send a JSON content type

Add Content-Type: application/json to the headers of every API-created webhook, and parse the raw body yourself in case one is missing.

Deduplicate on payload IDs

There is no delivery ID header. Use message_id for messaging, email_id for email, event_id for tracking, and account_id with the status for account events. Combine each with event where one ID can appear in several events, such as a message that is received and later read. See our guide to webhook idempotency.

Separate your own messages from incoming ones

Sent messages and emails arrive as message_received and mail_received. Check sender.attendee_provider_id against account_info.user_id, or origin for email, before you trigger replies or notifications, or you may react to your own messages.

Don't rely on new_relation for timing

Accepted invitations can take up to 8 hours to arrive. If you need to react sooner to an invitation that included a note, Unipile suggests watching for the new chat it creates through the messaging webhook.

Acknowledge fast and process asynchronously

Verify the header, store the event, return 200, and do the work in a background job. See why to process webhooks asynchronously.

Conclusion

Unipile doesn't sign its webhooks, so the secret header you add at creation is your endpoint's only protection. Add it, and a JSON content type, to every webhook, check it in constant time, handle the separate AccountStatus shape, and deduplicate on the IDs in each payload, because Unipile sends no delivery ID and gives up after five retries.

Hookdeck Event Gateway verifies your Unipile header and stops requests that fail before they reach your service, routes messages, emails and account statuses to the right services, and holds every event until your service has processed it.

Get started with Hookdeck for free and handle Unipile webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.