Integrations
Webhooks
Contaktly can send every company it identifies on your website to a URL of yours, as signed JSON, so it lands in your CRM, a Zapier or Make scenario, or your own system. This page is for whoever builds the receiving end.
- Delivery
- An HTTPS POST with a JSON body
- Signed
- HMAC-SHA256 with your own secret
- Retries
- 6 attempts over 36 hours
- Cost
- Included in every plan, uses no identifications
Set it up
- In Contaktly, open Settings, Integrations and find Any other tool. Paste the https address of your receiver and save it.
- Copy the signing secret that appears. It is shown once. Keep it where your receiver can read it, not in your code.
- Press Send an example. The card shows what your URL answered. The example is the
company.testevent with made-up data, in exactly the shape below. - Switch on Send automatically. From then on every newly identified company is sent. You can also send a single company from Identify with Send to your URL.
When we send
| Event | Sent when |
|---|---|
company.identified | A company visited your website and we identified it. Sent for every visit, so a company that comes back three times arrives three times, each time with its whole visit history so far. |
company.test | You pressed Send an example. The same shape, with made-up data. |
Automatic sends go out within about 15 minutes of the visit, once we have looked for the people at the company, which usually takes a minute, so the delivery carries them. Companies held because your workspace is over its monthly identification limit are not sent, and a later upgrade does not send them. Nothing else is filtered: a bounce arrives too, with visits.real set to false.
Automatic sends never verify an email address themselves, so they carry a person’s email only once someone has verified it on Identify. See emails below.
The request
Every delivery is one POST to your address with these headers and a JSON body.
| Header | Meaning |
|---|---|
Content-Type | Always application/json, UTF-8. |
User-Agent | Contaktly-Webhook/1. |
X-Contaktly-Event | The event, the same as event in the body: company.identified, or company.test for an example. |
X-Contaktly-Delivery | One id per delivery, the same on every retry of it. Keep the ones you have processed and skip a repeat. |
X-Contaktly-Attempt | 1 on the first try, rising with each retry, 6 at most. |
X-Contaktly-Signature | sha256= followed by the HMAC-SHA256 of the raw body, keyed with your signing secret, in lower-case hex. |
A complete delivery, as your server receives it:
POST /contaktly HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Contaktly-Webhook/1
X-Contaktly-Event: company.identified
X-Contaktly-Delivery: 3f6c1e2a-8b4d-4c7e-9a1f-5d2b7c9e0a14
X-Contaktly-Attempt: 1
X-Contaktly-Signature: sha256=bb26f3c5298c01eb07206b6beb0da38873054086561cac86b8f074dd410c2a5f
{
"version": 1,
"event": "company.identified",
"sentAt": "2026-09-23T09:14:07.000Z",
"workspaceId": "9d1b7c3e-2f4a-4b8c-8e6d-1a5f3c7b9e20",
"company": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Example Company",
"domain": "example.com",
"industry": "Software",
"employees": "11-50 employees",
"location": "Amsterdam, Netherlands",
"linkedin": "https://www.linkedin.com/company/example"
},
"visits": {
"count": 2,
"first": "2026-09-20T07:14:07.000Z",
"last": "2026-09-23T09:14:07.000Z",
"totalSeconds": 95,
"real": true,
"intent": "warm",
"pages": [
"/pricing",
"/"
]
},
"people": [
{
"id": "00000000-0000-0000-0000-000000000002",
"name": "Ben Sample",
"firstName": "Ben",
"lastName": "Sample",
"title": "Chief Executive Officer",
"email": null,
"emailStatus": "unverified",
"linkedin": "https://www.linkedin.com/in/ben-sample",
"phone": null
},
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Ann Example",
"firstName": "Ann",
"lastName": "Example",
"title": "Head of Marketing",
"email": "ann@example.com",
"emailStatus": "valid",
"linkedin": "https://www.linkedin.com/in/ann-example",
"phone": null
}
],
"link": "https://app.contaktly.com/identify?company=example.com"
}Shown indented for reading. The real body is compact JSON on one line, and the signature covers the exact bytes you receive.
To try your receiver before connecting it, this sends the same delivery exactly as we would, signed with the example secret further down:
curl -X POST 'https://hooks.example.com/contaktly' \
-H 'Content-Type: application/json' \
-H 'User-Agent: Contaktly-Webhook/1' \
-H 'X-Contaktly-Event: company.identified' \
-H 'X-Contaktly-Delivery: 3f6c1e2a-8b4d-4c7e-9a1f-5d2b7c9e0a14' \
-H 'X-Contaktly-Attempt: 1' \
-H 'X-Contaktly-Signature: sha256=bb26f3c5298c01eb07206b6beb0da38873054086561cac86b8f074dd410c2a5f' \
--data-binary '{"version":1,"event":"company.identified","sentAt":"2026-09-23T09:14:07.000Z","workspaceId":"9d1b7c3e-2f4a-4b8c-8e6d-1a5f3c7b9e20","company":{"id":"00000000-0000-0000-0000-000000000000","name":"Example Company","domain":"example.com","industry":"Software","employees":"11-50 employees","location":"Amsterdam, Netherlands","linkedin":"https://www.linkedin.com/company/example"},"visits":{"count":2,"first":"2026-09-20T07:14:07.000Z","last":"2026-09-23T09:14:07.000Z","totalSeconds":95,"real":true,"intent":"warm","pages":["/pricing","/"]},"people":[{"id":"00000000-0000-0000-0000-000000000002","name":"Ben Sample","firstName":"Ben","lastName":"Sample","title":"Chief Executive Officer","email":null,"emailStatus":"unverified","linkedin":"https://www.linkedin.com/in/ben-sample","phone":null},{"id":"00000000-0000-0000-0000-000000000001","name":"Ann Example","firstName":"Ann","lastName":"Example","title":"Head of Marketing","email":"ann@example.com","emailStatus":"valid","linkedin":"https://www.linkedin.com/in/ann-example","phone":null}],"link":"https://app.contaktly.com/identify?company=example.com"}'The payload
Emails are included only once verified
A delivery carries a person’s email only when we have verified it, and automatic sends do not verify addresses themselves: verifying is a deliberate step on Identify, where you choose the people you want. So most people in an automatic send arrive with email set to null and emailStatus set to unverified, with their name, title and LinkedIn profile. To send someone with their email, choose them on Identify, which verifies the address, and send the company from there with Send to your URL.
{
"version": 1,
"event": "company.identified",
"sentAt": "2026-09-23T09:14:07.000Z",
"workspaceId": "9d1b7c3e-2f4a-4b8c-8e6d-1a5f3c7b9e20",
"company": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Example Company",
"domain": "example.com",
"industry": "Software",
"employees": "11-50 employees",
"location": "Amsterdam, Netherlands",
"linkedin": "https://www.linkedin.com/company/example"
},
"visits": {
"count": 2,
"first": "2026-09-20T07:14:07.000Z",
"last": "2026-09-23T09:14:07.000Z",
"totalSeconds": 95,
"real": true,
"intent": "warm",
"pages": [
"/pricing",
"/"
]
},
"people": [
{
"id": "00000000-0000-0000-0000-000000000002",
"name": "Ben Sample",
"firstName": "Ben",
"lastName": "Sample",
"title": "Chief Executive Officer",
"email": null,
"emailStatus": "unverified",
"linkedin": "https://www.linkedin.com/in/ben-sample",
"phone": null
},
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Ann Example",
"firstName": "Ann",
"lastName": "Example",
"title": "Head of Marketing",
"email": "ann@example.com",
"emailStatus": "valid",
"linkedin": "https://www.linkedin.com/in/ann-example",
"phone": null
}
],
"link": "https://app.contaktly.com/identify?company=example.com"
}| Field | Type | Meaning |
|---|---|---|
| version | number | Always 1 for this shape. Within version 1 we only ever add fields. |
| event | string | company.identified for a real company. company.test for the example the Send an example button sends. |
| sentAt | string | When this attempt was sent, ISO 8601 in UTC. It is inside the signed body, so you can refuse a delivery that is too old. |
| workspaceId | string | Your Contaktly workspace. Useful when one receiver serves more than one workspace. |
| company | object | The company that visited. |
| company.id | string | The id of the company’s most recent visit. Each visit has its own, so use domain to recognise a company you already hold. |
| company.name | string | The company name, or its domain when we have no name. |
| company.domain | string or null | The company’s website domain, for example example.com. The best key for matching a company in your system. |
| company.industry | string or null | The industry as our data source names it. |
| company.employees | string or null | A size band as text, for example 11-50 employees. |
| company.location | string or null | City and country, where known. |
| company.linkedin | string or null | The company’s LinkedIn page. |
| visits | object | Every visit this company has made so far, added up. |
| visits.count | number | How many visits we have identified from this company. |
| visits.first | string or null | When the first visit was identified, ISO 8601 in UTC. |
| visits.last | string or null | When the most recent visit was identified, ISO 8601 in UTC. |
| visits.totalSeconds | number | Time on your site across all visits, in seconds. |
| visits.real | boolean | True when the visits add up to at least ten seconds or two pages. False is usually a bounce. |
| visits.intent | string | hot, warm or cool, from the pages they saw, the time they spent and how often they came back. |
| visits.pages | array of strings | The pages they viewed, as paths, most viewed first. |
| people | array | The decision-makers we found there, most senior first. Automatic sends carry up to five; a send from Identify carries the people you picked. Empty when we found nobody who fits your targeting. |
| people[].id | string | The person’s id in your workspace. The same person keeps the same id. |
| people[].name | string | Full name. |
| people[].firstName | string or null | First name. |
| people[].lastName | string or null | Last name. |
| people[].title | string or null | Job title, as it reads on their profile. |
| people[].email | string or null | A verified address, and only that. Anything we could not verify is null, never a guess. |
| people[].emailStatus | string or null | Why email is what it is. See the list below. |
| people[].linkedin | string or null | Their LinkedIn profile. |
| people[].phone | string or null | A phone number Identify looked up for this person, on workspaces where that is switched on. Null everywhere else. |
| link | string | Opens this company on Identify in Contaktly. |
emailStatus
- validVerified. The address is in email.
- unverifiedWe have not checked an address for this person yet. Choosing them on Identify checks it; a send after that carries it.
- checkingA check is running now.
- catch_allTheir mail server accepts every address, so none can be confirmed. email stays null.
- invalid, unknown, no_emailNo usable address was found.
- nullNothing has been looked up for this person.
Checking the signature
Check every delivery before you trust it. X-Contaktly-Signature is sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your signing secret, in hex. Compute the same over the bytes you received and compare the two in constant time.
Read the raw body before any JSON parser touches it. Parsing and writing it out again changes spacing or key order, and the signature no longer matches. Every example below reads the raw bytes.
const crypto = require('crypto')
function isFromContaktly(rawBody, signature, secret) {
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'))
const received = Buffer.from(signature || '')
return expected.length === received.length && crypto.timingSafeEqual(expected, received)
}const express = require('express')
const app = express()
// express.raw keeps the body as the exact bytes we sent.
app.post('/contaktly', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Contaktly-Signature')
if (!isFromContaktly(req.body, signature, process.env.CONTAKTLY_WEBHOOK_SECRET)) {
return res.sendStatus(401)
}
const delivery = JSON.parse(req.body)
// Skip it if you have seen req.get('X-Contaktly-Delivery') before,
// then store delivery.company and delivery.people.
res.sendStatus(200)
})
app.listen(3000)Check your code against a known answer
Before you connect, run your check with these three values. It must say yes, and no once you change a single character of the body.
whsec_5f2a9c0e7b1d4e8a9c3f6b2d7e1a4c8f0b3d6e9a2c5f8b1d{"version":1,"event":"company.identified","sentAt":"2026-09-23T09:14:07.000Z","workspaceId":"9d1b7c3e-2f4a-4b8c-8e6d-1a5f3c7b9e20","company":{"id":"00000000-0000-0000-0000-000000000000","name":"Example Company","domain":"example.com","industry":"Software","employees":"11-50 employees","location":"Amsterdam, Netherlands","linkedin":"https://www.linkedin.com/company/example"},"visits":{"count":2,"first":"2026-09-20T07:14:07.000Z","last":"2026-09-23T09:14:07.000Z","totalSeconds":95,"real":true,"intent":"warm","pages":["/pricing","/"]},"people":[{"id":"00000000-0000-0000-0000-000000000002","name":"Ben Sample","firstName":"Ben","lastName":"Sample","title":"Chief Executive Officer","email":null,"emailStatus":"unverified","linkedin":"https://www.linkedin.com/in/ben-sample","phone":null},{"id":"00000000-0000-0000-0000-000000000001","name":"Ann Example","firstName":"Ann","lastName":"Example","title":"Head of Marketing","email":"ann@example.com","emailStatus":"valid","linkedin":"https://www.linkedin.com/in/ann-example","phone":null}],"link":"https://app.contaktly.com/identify?company=example.com"}sha256=bb26f3c5298c01eb07206b6beb0da38873054086561cac86b8f074dd410c2a5fAnswering and retries
Answer with any 2xx status within 10 seconds and the delivery is done. What you put in the response body is ignored. If your work takes longer, store the body, answer 200 straight away and do the work after.
Anything else counts as a failure: another status, a redirect, a timeout or no connection at all. We do not follow redirects, so give us the final address.
An automatic send that fails is tried again after 15 minutes, 1 hour, 3 hours, 8 hours and 24 hours: 6 attempts over about 36 hours, then it stops. Every attempt of one delivery has the same X-Contaktly-Delivery id and a rising X-Contaktly-Attempt. Each attempt is built fresh, so a retry can carry people we found in the meantime, and it has a new sentAt.
A retry goes to the address saved at that moment, so fixing your address in Contaktly is enough: the deliveries still waiting go to the new one. Switching Send automatically off, or removing the address, cancels them. Sends from Identify and Send an example are not retried, because you see the answer straight away.
The Integrations card shows the last delivery and what your URL answered, or when we will try again.
Duplicates
The same company can reach you more than once: once per visit, and again if we retried a delivery whose answer was lost on the way back. Keep the X-Contaktly-Delivery ids you have processed and skip a repeat, and match companies on company.domain so a return visit updates the one you have.
Security
- We only send to https addresses, and refuse addresses with a password in them, bare IP addresses and localhost.
- Check the signature on every delivery, as above. It proves the body came from us and that nobody changed it.
sentAtis signed too. To refuse a replayed delivery, reject any whosesentAtis more than five minutes old. Retries are built fresh, so they pass.- Our servers run on Vercel in Frankfurt and have no fixed IP addresses, so an allow-list of addresses will not work. The signature is the check.
- Keep the secret out of your code and out of version control. Saving a new address issues a new secret and the old one stops working at once. Removing the address deletes the secret.
Versioning
version is 1. Within version 1 we only add fields: we never rename one, remove one or change what it means. Ignore fields you do not recognise and your receiver keeps working. A change that would break a receiver would come as version 2, announced to every workspace with a webhook well ahead, never shipped quietly.
Changes
- 23/09/2026Automatic sends are retried for 36 hours, with the new
X-Contaktly-Attemptheader. Automatic sends wait for the people search, so they carry the people. This page. - 17/09/2026Webhooks launched.
Stuck, or something here is unclear? Email support@contaktly.com with the X-Contaktly-Delivery id of the delivery and we can trace exactly what happened to it.