Webhooks
Receive campaign events as signed HTTP POST requests on your own endpoints, and keep your CRM or database in sync without polling.
Available events
contact.state_changed | Fires whenever a contact moves through the LinkedIn campaign pipeline: invited, accepted, messaged, responded, failed, skipped, and every other pipeline state. |
|---|---|
contact.interested | Fires when the AI detects an interested contact in a conversation, together with the reason and suggested next steps. |
contact.not_interested | Fires when the AI detects a contact who is not interested in a conversation, together with the reason. No tips for this event. |
contact.matched | Fires when a contact matches the campaign persona after a profile visit, together with the match score, reason and signals. |
contact.not_matched | Fires when a contact does not match the campaign persona after a profile visit, together with the match score, reason and signals. |
Setting up a webhook
Webhooks are managed through the API. Create an API key from Settings → Developers and use it with these endpoints:
GET /v1/webhooks | List your webhook endpoints |
|---|---|
POST /v1/webhooks | Create a webhook endpoint |
DELETE /v1/webhooks/{webhookId} | Delete a webhook endpoint (deliveries stop immediately) |
Example: subscribe an endpoint to several events.
curl -X POST https://app.useoutly.com/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hook.example.com/outly",
"events": ["contact.state_changed", "contact.interested", "contact.matched"]
}'
The response includes the signing secret. It is returned only once,
so store it safely. Endpoint URLs must use HTTPS.
A webhook belongs to a workspace. Without team_id it is personal
and fires for your personal campaigns. With team_id it is a shared
team webhook (team admins only): it fires for all of that team's campaigns and
every admin can see and manage it. Your team id is in the team
field of GET /v1/user/profile.
Delivery format
Every delivery is a JSON POST request with two headers:
X-Outly-Event | The event name |
|---|---|
X-Outly-Signature | HMAC-SHA256 hex digest of the raw request body, keyed with your signing secret |
contact.state_changed
{
"event": "contact.state_changed",
"occurred_at": "2026-08-19T12:00:00+00:00",
"campaign": { "id": 625, "name": "Q3 Outreach" },
"contact": {
"id": 41,
"first_name": "Jane",
"last_name": "Doe",
"linkedin_url": "https://www.linkedin.com/in/jane-doe"
},
"data": { "channel": "linkedin", "from": "invited", "to": "accepted" }
} data.channel is currently always linkedin (more
channels may be added later).
data.from and data.to carry the previous and new pipeline states.
Filter on data.to to react to the transitions you care about.
contact.interested
{
"event": "contact.interested",
"occurred_at": "2026-08-19T12:00:00+00:00",
"campaign": { "id": 625, "name": "Q3 Outreach" },
"contact": {
"id": 41,
"first_name": "Jane",
"last_name": "Doe",
"linkedin_url": "https://www.linkedin.com/in/jane-doe"
},
"data": {
"interested": true,
"reason": "Asked for pricing details",
"tips": "Suggest a short intro call this week."
}
} contact.not_interested
{
"event": "contact.not_interested",
"occurred_at": "2026-08-19T12:00:00+00:00",
"campaign": { "id": 625, "name": "Q3 Outreach" },
"contact": {
"id": 41,
"first_name": "Jane",
"last_name": "Doe",
"linkedin_url": "https://www.linkedin.com/in/jane-doe"
},
"data": {
"interested": false,
"reason": "Declined politely, not looking for a new provider"
}
} contact.matched and contact.not_matched
{
"event": "contact.matched",
"occurred_at": "2026-08-19T12:00:00+00:00",
"campaign": { "id": 625, "name": "Q3 Outreach" },
"contact": {
"id": 41,
"first_name": "Jane",
"last_name": "Doe",
"linkedin_url": "https://www.linkedin.com/in/jane-doe"
},
"data": {
"channel": "linkedin",
"is_match": true,
"match_score": 85,
"reason": "Head of Sales at a B2B SaaS scale-up",
"signals": [
{
"signal_type": "buying_intent",
"signal_group": "persona",
"confidence": 80,
"source_type": "own_post",
"url": "https://www.linkedin.com/posts/jane-doe_outbound-activity-1234",
"date": "2026-08-10",
"excerpt": "We are evaluating outbound tools for Q4.",
"interaction_text": null,
"why_it_matters": "Actively looking for a solution in your category."
}
]
}
}
Both events share the same payload: data.is_match is true
for contact.matched and false for contact.not_matched.
data.channel is the channel the profile was visited on.
data.signals holds up to 5 signal objects that support the decision. Each has
signal_type, signal_group (persona, company_momentum or engagement),
confidence (0 to 100), source_type, url, date,
excerpt, interaction_text and why_it_matters. Every signal carries all nine keys, unset ones are null.
data mirrors the stored match activity: match_score and signals are whatever
was recorded for it, so they are null and empty when nothing was recorded, for example when a user
marks a contact as matched by hand. Campaigns with AI matching disabled record a match with
match_score 100 and no signals for every visited contact.
Verifying signatures
The signature algorithm is HMAC-SHA256: the header value is
the lowercase hex digest of the raw request body, keyed with your signing
secret. Always verify X-Outly-Signature before trusting a
delivery:
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidSignature(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest();
const received = Buffer.from(signatureHeader ?? "", "hex");
return expected.length === received.length && timingSafeEqual(expected, received);
} Retries
A delivery counts as successful when your endpoint responds with a 2xx status within 30 seconds. Redirects are not followed. Failed deliveries are retried up to 6 times with exponential backoff (1 minute, 5 minutes, 30 minutes, 2 hours, 1 day, 1 week), so a delivery keeps retrying for over a week before it is dropped. Respond quickly and process the payload asynchronously if your handling is slow.