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:
- 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
headersarray and Unipile sends it on every delivery. - Payloads differ by
source. Messaging, email, email tracking and users webhooks send a flat object with aneventfield. Account status webhooks send{"AccountStatus": {...}}with noeventfield. - No JSON content type by default for webhooks created through the API. Add
{"key": "Content-Type", "value": "application/json"}toheaders, 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-Authheader? - How do I handle
message_received,message_readandmail_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
source | Events | Payload shape |
|---|---|---|
messaging | message_received (default), message_read, message_reaction, message_edited, message_deleted, message_delivered | Flat: account_id, account_type, event, chat_id, message_id, message, sender, attendees, attachments |
email | mail_received (default), mail_sent, mail_moved | Flat: email_id, account_id, event, from_attendee, to_attendees, subject, body, origin |
email_tracking | mail_opened, mail_link_clicked | Flat: event, event_id, tracking_id, email_id, account_id, url |
users | new_relation | Flat, with event |
account_status | message is CREATION_SUCCESS, RECONNECTED, SYNC_SUCCESS, OK, CREDENTIALS, ERROR, STOPPED, DELETED, ... | {"AccountStatus": {"account_ |
Notes:
- Sent messages and emails are delivered too (
message_received/mail_received). For messages, compareaccount_info.user_idwithsender.attendee_to tell your own from others. For emails,provider_ id originisunipileorexternal. - Account status
messagevalues includeOK,CREDENTIALS,ERROR/STOPPED,CONNECTING,DELETED,CREATION_SUCCESS,RECONNECTEDandSYNC_SUCCESS. When the status isn'tOK, Unipile stops sending new-message webhooks for that account, so surfaceCREDENTIALSto the user and start a reconnect. - Dispatch on
AccountStatusfirst, then onevent. Acknowledge events you don't handle with200.
Delivery, Retries and Idempotency
- Reply with a
200in 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), oraccount_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
- references/overview.md - Sources, events, payload examples, retries
- references/setup.md - Creating webhooks with the API or dashboard, adding headers, testing
- references/verification.md - Why there is no signature, the shared-secret header pattern, gotchas