Developers

Webhooks

Get every report on your own server as soon as it is done, instead of polling the API. Vintoo sends it as a signed POST request with JSON.

Set up

Owners enter a URL under Settings → Webhooks. The URL must use https on the default port (443) and point to a public address. There is one such URL per organization.

Click Send test to send an example report to your URL. You see right away whether it arrived and which HTTP status your server returned. A test uses no credits.

A list can also have its own URL, for example to send the prospects of one list to an outreach tool. Set it in the dashboard with Edit list. The report of a prospect in that list goes there as well as to the URL above: when it is finished, when an email or phone number is found later, and right away when a prospect that already has a report joins the list (in the dashboard, or via the API with list_ids). The verdict filters, Only with contact details and the signing secret of Settings → Webhooks apply to these URLs too. A URL you add later sends nothing for the past; to send the prospects already in the list, select them and use Send to webhook.

Events

Every webhook has a type:

TypeWhen
assessment.completedA report is done. data.report holds the report.
assessment.updatedAn email address or phone number was found later for a contact in an existing report, with View in the dashboard. data is the full report again: update the record you have for data.id, or create it if you have none yet.
assessment.failedA report could not be written. data.status is failed and data.report is null.
webhook.testSent by the test button, with example data. Answer it like any other webhook.

Vintoo sends every report of your organization, whether it started in the dashboard, through the API or MCP, or by monitoring. When you add a prospect through the API that already has a report, that report is sent right away.

Under Settings → Webhooks owners choose which verdicts are sent: go, wait and skip, all three by default. A report with verdict none (too little to judge) follows skip. assessment.failed is always sent. Switch on Only with contact details and a report is only sent when an email address or phone number of the decision-maker was found, or a general email address of the company; while contact details are still being looked up, the webhook waits for them. If View finds one later, the report is sent as assessment.updated, also when it was not sent before. Reports you send by hand from the prospect list and the test button ignore these choices.

The request

Each webhook is a POST request with a JSON body and these headers:

HeaderMeaning
Content-TypeAlways application/json.
User-AgentAlways Vintoo-Webhooks/1.0.
Vintoo-Event-IdThe same as id in the body.
Vintoo-SignatureThe signature. See Verify the signature below.
Request headers
POST /webhooks/vintoo HTTP/1.1
Content-Type: application/json
User-Agent: Vintoo-Webhooks/1.0
Vintoo-Event-Id: 5f1b8a0e-0000-4000-8000-000000000001
Vintoo-Signature: t=1767258002,v1=<hex HMAC-SHA256, 64 characters>

Example

A complete assessment.completed webhook. The test button sends this same data.

assessment.completed
{
  "id": "5f1b8a0e-0000-4000-8000-000000000001",
  "type": "assessment.completed",
  "created_at": "2026-01-01T09:00:02.000Z",
  "livemode": true,
  "data": {
    "id": "00000000-0000-4000-8000-000000000000",
    "company": {
      "name": "Example B.V.",
      "website_url": "https://example.com",
      "linkedin_url": "https://www.linkedin.com/company/example",
      "sector": "software_development",
      "email": "hello@example.com",
      "phone": "+31 30 123 4567",
      "employee_count": 25,
      "size_type": "company",
      "founded_year": "2015",
      "address": {
        "street": "Voorbeeldstraat 1",
        "postal_code": "3511 AA",
        "city": "Utrecht",
        "province": "Utrecht",
        "country": "NL"
      },
      "profile": {
        "services": [
          "Builds customer portals for installation companies.",
          "Connects the portals to planning and invoicing software."
        ],
        "customers": "Installation companies in the Netherlands.",
        "business_model": "Project fee plus a monthly license per portal.",
        "channels": [
          "Website",
          "Trade fairs",
          "LinkedIn"
        ],
        "delivery": "After an intake the team builds the portal and maintains it per customer.",
        "uncertainties": [
          "Pricing is not on the website."
        ]
      },
      "status": "completed"
    },
    "status": "completed",
    "stage": "qualified",
    "note": null,
    "lists": [],
    "report": {
      "modules": [
        "assessment.basic",
        "assessment.signals",
        "assessment.contact_persons",
        "assessment.contact_persons.email",
        "assessment.contact_persons.phone"
      ],
      "verdict": "go",
      "reason": "A new head of marketing started last month and is hiring.",
      "rationale": "Reach out to the new head of marketing now: started last month and is building a team. Note: check who owns the agency budget first.",
      "watch_for": null,
      "assessed_at": "2026-01-01T09:00:00.000Z",
      "questions": {
        "fit": 0.8,
        "pain": 0.6,
        "timing": 0.7,
        "intent": 0.5,
        "access": 0.6
      },
      "signals": [
        {
          "category": "personnel_change",
          "direction": "for",
          "importance": 0.8,
          "top": true,
          "label": "New head of marketing",
          "why": "A new decision-maker often reviews suppliers in the first months.",
          "evidence": "Sam de Vries started as Head of Marketing in December 2025.",
          "source": "linkedin",
          "url": "https://www.linkedin.com/in/example",
          "date": "2025-12-01"
        },
        {
          "category": "hiring",
          "direction": "for",
          "importance": 0.6,
          "top": false,
          "label": "Hiring a marketing manager",
          "why": "A growing marketing team needs support to get up to speed.",
          "evidence": "Vacancy: Marketing Manager, Utrecht.",
          "source": "site",
          "url": "https://example.com/jobs/marketing-manager",
          "date": "2025-12-10"
        },
        {
          "category": "firmographic",
          "direction": "against",
          "importance": 0.2,
          "top": false,
          "label": "Small team",
          "why": "With 25 employees the budget for outside help is limited.",
          "evidence": null,
          "source": "linkedin",
          "url": null,
          "date": null
        }
      ],
      "hooks": [
        "Congratulate Sam on the new role as head of marketing."
      ],
      "jobs": [
        {
          "title": "Marketing Manager",
          "url": "https://example.com/jobs/marketing-manager",
          "department": "marketing",
          "seniority": "senior",
          "location": "Utrecht",
          "posted_on": "2025-12-10",
          "relevance": {
            "kind": "decision_maker",
            "role": "Head of Marketing"
          }
        }
      ],
      "reviews": [
        {
          "text": "Good portal, but support takes days to reply.",
          "quote": "support takes days to reply.",
          "date": "2025-11-20"
        }
      ],
      "site": {
        "last_updated_on": "2025-12-15",
        "last_updated_source": "sitemap",
        "copyright_year": 2026
      },
      "tech": {
        "source_url": "https://example.com/",
        "detected_at": "2026-01-01T08:55:00.000Z",
        "groups": [
          "marketing_stack",
          "saas_stack"
        ],
        "is_webshop": false,
        "ecommerce_platform": null,
        "partner_badges": [],
        "marketing_tools": [
          "HubSpot"
        ],
        "chat_widgets": [],
        "tool_history": {
          "since": "2023-01-12",
          "snapshot_count": 7,
          "changes": [
            {
              "field": "marketing_tools",
              "name": "HubSpot",
              "kind": "added",
              "after": "2025-03-04",
              "before": "2025-06-11",
              "archive_url": "https://web.archive.org/web/20250611093012/https://example.com/",
              "replaced": [
                "Mailchimp"
              ]
            },
            {
              "field": "marketing_tools",
              "name": "Mailchimp",
              "kind": "removed",
              "after": "2025-03-04",
              "before": "2025-06-11",
              "archive_url": "https://web.archive.org/web/20250304120455/https://example.com/",
              "replaced": []
            }
          ]
        },
        "dns": {
          "domain": "example.com",
          "verified_services": [
            "Microsoft 365"
          ],
          "dns_provider": "Cloudflare"
        }
      }
    },
    "contacts": [
      {
        "name": "Sam de Vries",
        "title": "Head of Marketing",
        "email": "sam@example.com",
        "phone": "+31 6 111 111 11",
        "linkedin_url": "https://www.linkedin.com/in/example",
        "why": "Decides on marketing suppliers.",
        "in_role_since": "2025-12-01"
      },
      {
        "name": "Noor Jansen",
        "title": "CEO",
        "email": null,
        "phone": null,
        "linkedin_url": "https://www.linkedin.com/in/example-ceo",
        "why": "Signs off on larger budgets.",
        "in_role_since": "2015-03-01"
      }
    ],
    "created_at": "2025-12-20T14:30:00.000Z",
    "updated_at": "2026-01-01T09:00:00.000Z"
  }
}

data is the same object the API returns for one prospect, see the API reference. A field without a value is null or an empty list and is never left out. The exception is data.report.tech, which only holds the parts that count for your offering.

Fields

The main fields. The API reference lists every field and every possible value.

FieldMeaning
idUnique per webhook and the same on every retry. Use it to skip duplicates.
typeassessment.completed, assessment.updated, assessment.failed or webhook.test.
created_atWhen the webhook was first sent.
livemodefalse for the test button and for reports from a test API key: example data, not a real report.
data.idThe prospect. The same id the API returns when you add it: use it to match the webhook to your own records.
data.companyName, website, LinkedIn page, email, phone, number of employees, year founded and address.
data.company.sectorThe sector Vintoo recognized on the website, as a fixed value such as software_development.
data.company.profileWhat the company offers, who it sells to, how it finds customers and delivers its work, and what the website leaves unclear.
data.statuscompleted or failed.
data.stage, data.noteThe pipeline stage and note of your team.
data.report.verdictgo (approach now), wait (not yet), skip (not a fit) or none (not enough information). See How reports work.
data.report.reason, rationaleThe verdict in one sentence, and a slightly longer explanation.
data.report.watch_forFor wait: what would make it worth reaching out.
data.report.signalsThe dated facts behind the verdict, for or against, with source and link. The top signal comes first. See Signals.
data.report.hooksUp to 3 direct occasions to reach out, most relevant first. Empty for none, and for go, wait and skip when no real occasion was found. See Decision-makers and hooks.
data.report.jobsUp to 10 open vacancies, with department, level and posting date.
data.report.reviewsThe Google reviews behind the review signals, newest first, each with quote: the part about the signal's topic.
data.report.siteWhen the website was last updated.
data.report.techTechnology on the website and domain, limited to what counts for your offering.
data.report.questionsProbability from 0 to 1 per question: fit, pain, timing, intent and access. Not shown in the dashboard.
data.contactsUp to 3 people to approach. The first one is the decision-maker.

Respond and retries

  • Answer with any 2xx status within 10 seconds. Anything else counts as failed, redirects too.
  • After a failure Vintoo tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After that the webhook is marked as failed.
  • A webhook can arrive more than once. Skip an id you already handled.
  • When you change the URL, open retries go to the new URL. When you remove it, they stop.

Verify the signature

Every webhook is signed with the signing secret under Settings → Webhooks, which only owners can see. The Vintoo-Signature header holds t=…,v1=…: t is the time in unix seconds and v1 the hex HMAC-SHA256 of the time, a dot and the raw body.

Node.js with Express
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const secret = process.env.VINTOO_WEBHOOK_SECRET; // whsec_...

// rawBody: the request body exactly as received, before JSON.parse
const isFromWebhookSender = (rawBody, header, secret) => {
  const { t, v1 = "" } = Object.fromEntries(
    header.split(",").map((part) => part.split("="))
  );
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;

  return (
    fresh &&
    v1.length === expected.length &&
    timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  );
};

const app = express();

app.post(
  "/webhooks/vintoo",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");

    if (!isFromWebhookSender(rawBody, req.get("Vintoo-Signature") ?? "", secret)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(rawBody);

    // Answer first, then do the work: Vintoo waits at most 10 seconds.
    res.sendStatus(200);

    // A webhook can arrive twice: skip ids you already handled.
    if (alreadyHandled(event.id)) return;

    if (
      event.type === "assessment.completed" ||
      event.type === "assessment.updated"
    ) {
      saveReport(event.data.id, event.data);
    }
  }
);

Compute the signature over the raw body, before you parse the JSON: parsing and serializing again changes the bytes. Reject webhooks older than 5 minutes. After you rotate the secret, new webhooks are signed with the new one right away.

Log

Settings → Webhooks shows every webhook of the last 30 days with its status. Click a row to see what was sent, how your server responded and when the next attempt is. Send again sends it as a new webhook with a new id.