← Back to API Reference

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/webhooksList your webhook endpoints
POST /v1/webhooksCreate 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-EventThe event name
X-Outly-SignatureHMAC-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.