> ## Documentation Index
> Fetch the complete documentation index at: https://dev.gethuntd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signup webhooks

> Get notified when someone at a tracked account signs up

Register an HTTPS endpoint and Huntd will POST to it when someone at one of your tracked accounts
signs up for a source.

<Note>
  This is **not** the same as [Lookup Webhooks](/api-reference/webhooks), which deliver Company
  Lookup job results (`company_lookup.completed`). The two are unrelated — make sure you are
  wiring up the one you want.
</Note>

## Registering an endpoint

Go to **Settings → API & webhooks** in the [Huntd Dashboard](https://app.gethuntd.com).

<Steps>
  <Step title="Add your URL">
    It must be a **public `https://` URL**. Redirects are not followed.
  </Step>

  <Step title="Copy your signing secret">
    You receive a signing secret (`whsec_…`). Unlike an API key it **stays readable**, because you
    need it to verify our signature. It grants no access to Huntd.
  </Step>

  <Step title="Send a test">
    Use the **Send test** button to fire a synthetic event, so you can confirm your receiver works
    before any real traffic arrives.
  </Step>
</Steps>

## The request we send

```http theme={null}
POST /their/endpoint HTTP/1.1
Content-Type: application/json
X-Huntd-Event: signup
X-Huntd-Delivery: 1041
X-Huntd-Timestamp: 1785400000
X-Huntd-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015…
```

```json theme={null}
{
  "event": "signup",
  "id": "e117b1d2-5a34-4943-8a1d-6201be9437a0",
  "occurredAt": "2026-07-31T20:00:05.460Z",
  "organization": "acme.com",
  "data": {
    "source": "devin",
    "email": "mike.burton@globex.com",
    "firstName": "Mike",
    "lastName": "Burton",
    "jobTitle": "Director, IT Architecture",
    "company": "Globex",
    "companyDomain": "globex.com",
    "industry": "financial services,insurance",
    "location": "Columbus, OH, United States",
    "linkedinUrl": "https://linkedin.com/in/example",
    "verifiedAt": "2026-07-31T19:01:57.707Z",
    "previouslyNotUserAt": "2026-07-23T20:02:28.302Z"
  }
}
```

### Fields

<ResponseField name="event" type="string">
  Event type. Only `signup` exists today.
</ResponseField>

<ResponseField name="id" type="string">
  Unique event ID. **Key on this for idempotency** — see [delivery](#delivery-behaviour).
</ResponseField>

<ResponseField name="occurredAt" type="string">
  UTC timestamp of when we detected the signup.
</ResponseField>

<ResponseField name="organization" type="string">
  Your organization's domain.
</ResponseField>

<ResponseField name="data.verifiedAt" type="string">
  When we confirmed this person **is** a user.
</ResponseField>

<ResponseField name="data.previouslyNotUserAt" type="string">
  When we last confirmed they were **not** a user. The signup happened between these two
  timestamps.
</ResponseField>

## Verifying the signature

Compute HMAC-SHA256 over `` `${timestamp}.${rawBody}` `` using your signing secret, and compare it
to `X-Huntd-Signature`.

<Warning>
  Use the **raw request body, before JSON parsing**. Re-serializing a parsed object changes the
  bytes and the signature will not match.
</Warning>

The timestamp is part of the signed material, so a captured payload cannot be replayed with a
fresh header. Reject anything older than about 5 minutes.

```javascript theme={null}
import crypto from 'node:crypto';

function verify(req, rawBody, secret) {
  const ts = req.headers['x-huntd-timestamp'];
  const sig = String(req.headers['x-huntd-signature'] || '').replace(/^sha256=/, '');
  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(sig, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
  return Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}
```

## Delivery behaviour

* **Acknowledge with any `2xx`.** The timeout is **10 seconds** — respond fast and do your work
  asynchronously.
* **Retries:** 5 attempts with backoff — 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours — after
  which the delivery is marked failed.
* **Redirects are not followed.**
* **No replay on registration.** A newly registered endpoint starts from "now". To backfill, use
  [`/people?discovered_days=N`](/public-api/people).

### At-least-once delivery

Each event is queued at most once per endpoint, so retrying a *failed* delivery does not create
duplicates. But if we time out **after** your server already processed the event, the retry is a
genuine duplicate.

<Warning>
  **Key on `id` and make your handler idempotent.** This is the single most common source of
  double-processing.
</Warning>

### Unknown event types

Only `event: "signup"` exists today, but the payload is event-typed via both the `event` field and
the `X-Huntd-Event` header. **Ignore event types you do not recognize** rather than erroring, so
new event types do not break your receiver.

## Two caveats worth reading

<AccordionGroup>
  <Accordion title="Timing — this is not real-time">
    The event fires when Huntd **detects** the signup, not the moment it happens. We re-verify
    accounts daily and check for changes hourly, so an event typically arrives **within a few
    hours**.

    That is timely enough for a Slack alert and saves you polling entirely — but do not build
    anything that assumes sub-minute delivery.
  </Accordion>

  <Accordion title="Entitlement — Signup Signals must be enabled">
    Signup webhooks only deliver for organizations with **Signup Signals** enabled. Without it you
    can still register an endpoint and use **Send test** successfully, but **no live events will
    ever arrive**.

    If your test works and real events never come, check your entitlement first.
  </Accordion>
</AccordionGroup>
