# Guide to TheirStack Webhooks: Features and Best Practices

TheirStack webhooks push hiring and technology signals to your systems as TheirStack finds them: a new job posting that matches your search, a company that starts matching it, or a job that has closed. If you use TheirStack's job and company data for sales prospecting, market research or recruiting, webhooks turn a saved search into a live feed for your CRM, Slack or automation tool.

This guide covers how TheirStack webhooks are set up and signed, the event types and their payloads, how credits and daily limits shape what you receive, and the practices that keep a TheirStack receiver reliable.

## What are TheirStack webhooks?

A TheirStack webhook is attached to a saved search. Every time TheirStack finds a new record matching that search, it sends one JSON `POST` with that one record. Each event costs API credits, the same as an API request.

If you set a signing secret on the webhook, each delivery carries an `X-TheirStack-Signature-256` header in the same format GitHub uses: `sha256=` followed by the hex HMAC-SHA256 of the raw body. Without a secret, the header isn't sent.

## TheirStack webhook features

| Feature | Details |
| --- | --- |
| Configuration | The TheirStack app, or `POST /v0/webhooks` and `PATCH /v0/webhooks/{id}` |
| Trigger | A saved job, company or technology search |
| Records per request | One |
| Event type | `type` in the body |
| Signature header | `X-TheirStack-Signature-256`, only when a signing secret is set |
| Scheme | HMAC-SHA256, lowercase hex, prefixed `sha256=` |
| Signed content | The raw request body, nothing else |
| Secret | Set or generated on the webhook, minimum 16 characters |
| Cost | 1 credit for `job.new` and `job.closed`, 3 for `company.new` and `tech.new` |
| Daily limit | Optional `max_events_per_day`. Records above it are dropped |
| Success response | Any `2xx` |
| Retries | Every hour for 48 hours |
| Concurrency | Your endpoint must handle at least 2 concurrent requests |

TheirStack doesn't publish source IPs or a response timeout.

## Common events

| Event | Fires when | `payload` |
| --- | --- | --- |
| `job.new` | TheirStack discovers a new job posting that matches your search | The full Job object |
| `company.new` | A company matches your search for the first time on this webhook | The full Company object |
| `job.closed` | A matching job is closed or removed by its source | Described as `{ "id": ..., "closed_at": ... }` |
| `tech.new` | A company adopts a new technology | Access on request, payload not documented |

Every body has the same envelope: a top-level `id` for the webhook event, the `type`, and the record under `payload`:

```json
{
  "id": 123456,
  "type": "job.closed",
  "payload": {
    "id": 123,
    "closed_at": "2026-06-08T14:00:00Z"
  }
}

```

The values are illustrative: TheirStack publishes field tables for each event type but no example bodies. The `payload` shape for `job.closed` comes from TheirStack's endpoint guide. Its event reference page for `job.closed` lists the full Job schema instead, so handle both.

`company.new` is "new" relative to the webhook, not to TheirStack's database. A company that has been hiring for years still fires the first time it matches, and never again on that webhook.

`job.new` payloads are large. Besides title, location and salary fields, they include `description` (in Markdown), `company_object`, `hiring_team`, `keyword_slugs` and, for keyword searches, `matching_phrases`. The `company` string field is deprecated: use `company_object` or `company_domain`.

## Setting up TheirStack webhooks

1. Create and save a search in TheirStack: jobs, companies or technologies.
2. Create a webhook for it, in the app or through the API, with your endpoint URL and the event type.
3. Set a **Signing Secret**, at least 16 characters. The app can generate one, or you can pass `secret` on `POST /v0/webhooks` or `PATCH /v0/webhooks/{id}`. Store it server-side, for example as `THEIRSTACK_WEBHOOK_SECRET`.
4. Decide on a daily event limit. Without one, a broad search can spend a lot of credits quickly.

If you receive TheirStack webhooks through Hookdeck, create a source with the TheirStack type, paste the signing secret, and use the source URL as the webhook URL. Set a signing secret on the TheirStack webhook: without one, deliveries are unsigned and fail verification.

## Securing TheirStack webhooks

Read `X-TheirStack-Signature-256`, check it starts with `sha256=`, compute the hex HMAC-SHA256 of the raw body with your secret, and compare in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyTheirStackSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret || !signatureHeader.startsWith('sha256=')) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.slice('sha256='.length).trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/theirstack', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.THEIRSTACK_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyTheirStackSignature(req.body, req.get('x-theirstack-signature-256'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type; deduplicate on event.type plus event.payload.id.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_theirstack_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret or not signature_header.startswith("sha256="):
        return False
    received = signature_header[len("sha256="):].strip().lower().encode("utf-8")
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().encode("utf-8")
    return hmac.compare_digest(received, expected)

# In FastAPI: raw_body = await request.body()
# verify_theirstack_signature(raw_body, request.headers.get("x-theirstack-signature-256"),
#                             os.environ["THEIRSTACK_WEBHOOK_SECRET"])

```

TheirStack publishes test values for your verifier: the secret `It's a Secret to Everybody` and the payload `Hello, World!` give `sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17`. These are the same values GitHub publishes. The payload isn't JSON, so use it to test the HMAC function on its own, not a handler that parses the body.

TheirStack's own sample returns `403` on a mismatch. Any non-`2xx` works, but bear in mind that TheirStack retries failed events every hour for 48 hours.

There is no timestamp in the request, so nothing tells you how old it is.

> **Make TheirStack webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-TheirStack-Signature-256` header, stores every job and company event, and delivers it to your service at a rate it can handle.

## TheirStack webhook limitations and pain points

### No secret means no signature

**The Problem:** Your verifier rejects every delivery because the header is missing.

**Why It Happens:** TheirStack only signs when the webhook has a signing secret. Webhooks created without one send no `X-TheirStack-Signature-256` header.

**Workarounds:**

* Edit the webhook and add a secret, or `PATCH /v0/webhooks/{id}` with `secret`.
* Treat a missing header as a failure, not as a reason to skip verification.

**How Hookdeck Can Help:** Hookdeck's TheirStack source verifies the header against your secret. Hookdeck answers TheirStack with a `200` either way, then records a request that fails the check as failed verification, so it never reaches your destination.

### Every event costs credits, including duplicates

**The Problem:** Credits run out faster than expected, and then events stop.

**Why It Happens:** Each event is billed on its own. Events aren't deduplicated against other webhooks or against records you already pulled through the API, so the same job can be billed twice. When credits run out, TheirStack pauses monitoring until you buy more.

**Workarounds:**

* Keep searches narrow and avoid overlapping webhooks.
* Set `max_events_per_day`, and use `GET /v0/webhooks/volume-estimate?search_id=...` to size it.
* Monitor your credit balance so a pause doesn't go unnoticed.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeat deliveries of the same record before they reach your service, so a duplicate costs you a credit but not a second CRM update.

### The daily limit drops records

**The Problem:** Some matching jobs never arrive.

**Why It Happens:** Records above the daily limit aren't sent and aren't queued for the next day. TheirStack does this on purpose to keep the feed current, and doesn't charge for them.

**Workarounds:**

* Raise the limit or narrow the search if you need every match.
* Check the app's warning, which estimates how many records a day you would lose.

**How Hookdeck Can Help:** Hookdeck shows every event that did arrive, so you can compare daily volume against what the search matches.

### Bursts and concurrency

**The Problem:** A newly created webhook on a broad search floods your endpoint.

**Why It Happens:** One record per request, with at least 2 concurrent requests, adds up quickly when a search matches many records at once.

**Workarounds:**

* Return `2xx` quickly and queue the work.
* Make sure your handler is safe to run concurrently.

**How Hookdeck Can Help:** Hookdeck accepts the burst and delivers to your service at a [max delivery rate](/docs/guides/control-throughput-queue-events) you set, with [retries](/docs/retries) for anything that fails.

## Best practices

### Verify against the raw body

TheirStack warns that re-serialised JSON can change key order or whitespace, and that payloads can contain Unicode, so handle the body as raw UTF-8 bytes. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `await request.text()`. In FastAPI, use `await request.body()`.

### Deduplicate on the record

TheirStack says duplicates are rare but possible, for example when two searches overlap, and suggests deduplicating on `id`. Key on `type` and `payload.id`, so a job's `job.new` and `job.closed` don't collide. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use `job.closed` to keep data fresh

If you store jobs from `job.new`, subscribe to `job.closed` as well, so closed roles drop out of your pipeline instead of lingering.

### Acknowledge fast and process asynchronously

Verify, store, return `200`, and enrich or route afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

TheirStack sends one record per webhook delivery from a saved search and, when the webhook has a signing secret, signs it with `X-TheirStack-Signature-256: sha256=<hex>`, an HMAC-SHA256 of the raw body. Always set the secret, route on `type`, deduplicate on the record ID, and set a daily limit so a broad search doesn't drain your credits.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every TheirStack signature, and smooths bursts so your service sees events at a pace it can handle.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle TheirStack webhooks reliably in minutes.