Agent skill

Unipile Webhooks Skill

Receive and authenticate Unipile webhooks (unified messaging, email and LinkedIn API). Use when building a Unipile webhook receiver, securing it with a shared secret header such as Unipile-Auth (Unipile has no HMAC signature), creating webhooks with POST /api/v1/webhooks, handling message_received, message_read, mail_received and account status (OK, CREDENTIALS) payloads, or debugging Unipile webhook retries and missing JSON content types.

Install this skill

npx skills add hookdeck/webhook-skills --skill unipile-webhooks


Unipile is a unified API for messaging (LinkedIn, WhatsApp, Instagram, Messenger, Telegram, X) and email (Google, Microsoft, IMAP). You create a webhook with POST /api/v1/webhooks (or in the Unipile dashboard), choose a source, and Unipile sends an HTTP POST to your request_url when something happens.

Things that differ from most providers:

  1. There is no signature. Unipile doesn't sign the body. No HMAC, no timestamp header. Instead you add your own secret to the webhook's headers array and Unipile sends it on every delivery.
  2. Payloads differ by source. Messaging, email, email tracking and users webhooks send a flat object with an event field. Account status webhooks send {"AccountStatus": {...}} with no event field.
  3. No JSON content type by default for webhooks created through the API. Add {"key": "Content-Type", "value": "application/json"} to headers, and parse the raw body yourself either way.

When to Use This Skill

  • How do I receive Unipile webhooks?
  • How do I verify Unipile webhook signatures? (You can't. Send a shared secret in a header instead.)
  • How do I create a Unipile webhook with the API and add an Unipile-Auth header?
  • How do I handle message_received, message_read and mail_received?
  • How do I detect an account that needs reconnecting (CREDENTIALS)?
  • Why does my Unipile webhook arrive more than once?

Verification (core): a shared secret header

Create the webhook with a secret in headers:

curl --request POST --url https://{YOUR_DSN}/api/v1/webhooks \
  --header 'X-API-KEY: XXXXXXXX' --header 'content-type: application/json' \
  --data '{
    "request_url": "https://example.com/webhooks/unipile",
    "source": "messaging",
    "headers": [
      { "key": "Content-Type", "value": "application/json" },
      { "key": "Unipile-Auth", "value": "<secret from openssl rand -hex 32>" }
    ]
  }'

Then check the header in constant time before you parse the body:

const crypto = require('crypto');

// NOT a Unipile signature. This is a secret YOU put in the webhook's headers array.
function verifyUnipileSecret(headerValue) {
  const expected = process.env.UNIPILE_WEBHOOK_SECRET;
  if (!expected) throw new Error('UNIPILE_WEBHOOK_SECRET is not set'); // fail closed
  if (typeof headerValue !== 'string') return false;
  const a = Buffer.from(headerValue);
  const b = Buffer.from(expected);
  // Length check first: timingSafeEqual throws on a length mismatch.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: verifyUnipileSecret(req.get('Unipile-Auth'))

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Return 401 for a missing or wrong secret and 500 when the secret isn't configured, so the endpoint never accepts everything by accident. The header name (Unipile-Auth) is your choice, and header names are case-insensitive. See references/verification.md.

Sources and Events

sourceEventsPayload shape
messagingmessage_received (default), message_read, message_reaction, message_edited, message_deleted, message_deliveredFlat: account_id, account_type, event, chat_id, message_id, message, sender, attendees, attachments
emailmail_received (default), mail_sent, mail_movedFlat: email_id, account_id, event, from_attendee, to_attendees, subject, body, origin
email_trackingmail_opened, mail_link_clickedFlat: event, event_id, tracking_id, email_id, account_id, url
usersnew_relationFlat, with event
account_statusmessage is CREATION_SUCCESS, RECONNECTED, SYNC_SUCCESS, OK, CREDENTIALS, ERROR, STOPPED, DELETED, ...{"AccountStatus": {"account_id", "account_type", "message"}}

Notes:

  • Sent messages and emails are delivered too (message_received / mail_received). For messages, compare account_info.user_id with sender.attendee_provider_id to tell your own from others. For emails, origin is unipile or external.
  • Account status message values include OK, CREDENTIALS, ERROR/STOPPED, CONNECTING, DELETED, CREATION_SUCCESS, RECONNECTED and SYNC_SUCCESS. When the status isn't OK, Unipile stops sending new-message webhooks for that account, so surface CREDENTIALS to the user and start a reconnect.
  • Dispatch on AccountStatus first, then on event. Acknowledge events you don't handle with 200.

Delivery, Retries and Idempotency

  • Reply with a 200 in under 30 seconds. Otherwise Unipile makes five retry attempts with an increasing delay between them.
  • There's no delivery ID header, so dedupe on payload fields: message_id (messaging), email_id (email), event_id (tracking), or account_id + status.
  • Messages that arrive while an account is disconnected are delivered after it reconnects. You won't receive webhooks for old messages when an account is first connected.
  • Do slow work after responding, not before.

Environment Variables

# REQUIRED. The value you put in the webhook's headers array. Generate with: openssl rand -hex 32
UNIPILE_WEBHOOK_SECRET=
# OPTIONAL. The header key (case-insensitive). Default: Unipile-Auth
UNIPILE_WEBHOOK_HEADER=Unipile-Auth

Local Development

npx hookdeck-cli listen 3000 unipile --path /webhooks/unipile

No account required. The CLI creates a guest account on first run and gives you a public HTTPS URL to use as request_url, plus a web UI for inspecting requests.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 2, 2026

View on GitHub →