SteveSignalsDocs

Webhooks

How webhooks work

One small POST per tracker run with counts and page tokens, the events read 100 per page from the API, every event name per action, filters, headers and delivery guarantees.

A webhook tells your URL that a tracker run found new events. You do not poll. You run an HTTPS endpoint, Signals POSTs one small run webhook to it, and you answer with a 2xx. Then you read the events themselves from the API, 100 per page.

Request
One POST per webhook and run, Content-Type: application/json
Body
A run webhook (version 3): the run, and per event name its total, totalPages and firstPageToken. Never the events. About 1 to 2 KB
Events
GET /api/public/v1/events?pageToken=… with an API key. 100 full events per page. See Read a run's events
When
A few minutes after a check finishes. A run with no matching event sends nothing
Scope
Per organization. Up to 5 endpoints
Filters
Events, actions, competitors, projects. Empty = all. Combined with AND
Delivery
At least once. Dedupe the POST on webhookId and each event on eventId
Cost
Free. A webhook never uses credits
Never sent
LinkedIn engagement (pull API only)

What gets sent

Each check of a tracker is a run. When a run finishes with at least one event that matches your filters, Signals sends your webhook one POST for that run. It says how many events of each name the run has, and gives you a token to read them.

Signals POSTs the run webhook

One request per webhook and run. Counts and tokens only.

You answer 2xx

Store the body, answer within 10 seconds, and do the rest in the background.

You read the pages

For each event name with a firstPageToken, call GET /api/public/v1/events?pageToken=…. Follow nextPageToken until it is null.

Here is a run of the ads tracker. It found three new ads, one per network, so ads.new has bySubEvent. ads.updated is listed with 0.

POST body · run webhook · ads (HubSpot)Sample with the exact shape (no real run yet)
json
1{
2 "version": 3,
3 "webhookId": "7fc0937d-6f0f-56c3-b6cf-5090cbb43015",
4 "type": "run.completed",
5 "eventType": "ads",
6 "sentAt": "2026-10-27T20:29:55.624Z",
7 "run": {
8 "id": "b0b5b63b-fc3d-4eef-84ab-879e2335c06d",
9 "trackerId": "3c8918c2-7011-4518-9185-31344c83861f",
10 "projectId": "2738158a-08fd-4fc4-912d-43d4c328bc10",
11 "source": "ads",
12 "completedAt": "2026-10-27T20:29:00.624Z",
13 "competitor": {
14 "name": "HubSpot",
15 "domain": "hubspot.com",
16 "subjectKey": "google:AR10072600183532683265@-|meta:https://www.facebook.com/hubspot|linkedin:hubspot"
17 }
18 },
19 "pageSize": 100,
20 "events": {
21 "ads.new": {
22 "total": 3,
23 "totalPages": 1,
24 "bySubEvent": {
25 "google_ad": 1,
26 "meta_ad": 1,
27 "linkedin_ad": 1
28 },
29 "firstPageToken": "<page token: ads.new, page 1>"
30 },
31 "ads.updated": {
32 "total": 0,
33 "totalPages": 0,
34 "firstPageToken": null
35 }
36 }
37}
Run webhook
version
3
The body version. Same value as the Signals-Event-Version header.
webhookId
string
This delivery's id. Same value as the Signals-Webhook-Id header. The same on every retry. Use it to drop a repeated POST.
type
'run.completed'
The only type today. Treat any other value as unknown.
eventType
'ads'|'reviews'|'social_post'|'mention'|'news'|'website'|'jobs'|'competitor'
The event family of the run. One run is one tracker, so one family.
sentAt
string
ISO 8601 time of this attempt.
run
object
The run. See below.
pageSize
number
Events per page: 100.
events
object
One section per event name the run's tracker can send, keyed by the name (ads.new, jobs.removed, …). Every name is listed, also with total: 0. See Event names per family.
run
id
string
The run id. Every event of the run has it as runId.
trackerId
string
The tracker that ran. Always set.
projectId
string|null
The tracker's project. Always set on a real run. Only a test delivery can carry null.
source
string
The tracker source: ads, reviews, linkedin, x, instagram, linkedin_mentions, reddit_mentions, news, website, job_postings or competitor_discovery.
completedAt
string
ISO 8601 time of the run.
competitor
object
{ name: string, domain: string | null, subjectKey: string }, the same object as on each event. Always set on a real run.
events[name]
total
number
How many events of this name the run has, after your webhook's filters.
totalPages
number
total divided by 100, rounded up. 0 when total is 0.
bySubEventoptional
object
total per subEventType, for example { "google_ad": 1, "meta_ad": 1 }. Only when the run mixes sub-types: ad networks or review portals.
firstPageToken
string|null
Opens page 1 of this name. null when total is 0.

The tokens in the examples are placeholders. Real tokens are opaque strings: never parse or change them.

Why counts, not events

A run can find thousands of items. The POST stays small and fast to verify. You read the events at your own pace, and you can read a page again: the same token returns the same page while your webhook's filters are unchanged.

Event names per family

Sections are keyed by the event name (signalType on each event). Each name belongs to one action. A run lists every name of its family, in this order, with total: 0 when it found none.

eventTypeTracker sourcesubEventTypenewupdatedremoved
adsadsgoogle_ad, meta_ad, linkedin_adads.newads.updated
reviewsreviewsg2_review, trustpilot_review, capterra_review, trustpilot_de_review, capterra_de_review, boxil_reviewreviews.newreviews.updated
social_postlinkedin, x, instagramlinkedin_post, x_post, instagram_postsocial.postsocial.post_updated
mentionlinkedin_mentions, reddit_mentionslinkedin_mention, reddit_mentionsocial.mentionsocial.mention_updated
newsnewsnews_articlenews.articlenews.article_updated
websitewebsitewebsite_changewebsite.page_addedwebsite.changed
jobsjob_postingsjob_postingjobs.newjobs.updatedjobs.removed
competitorcompetitor_discoverynew_competitormarket.new_competitormarket.competitor_updated

That is 8 families and 18 sub-types.

  • Only job postings send a removed name (jobs.removed, a closed job). The other trackers never emit one, so their run webhook has no removed section.
  • website.page_added is listed with total: 0. Today a website run only has website.changed.
  • bySubEvent appears only on ads and reviews runs, when the run found more than one network or portal.
  • engagement.* (LinkedIn engagement) is never sent by webhook.

A jobs run shows all three actions:

POST body · run webhook · jobs (Acme)Sample with the exact shape (no real run yet)
json
1{
2 "version": 3,
3 "webhookId": "ab78fcb4-5d5d-5224-9bc7-1a7025550b24",
4 "type": "run.completed",
5 "eventType": "jobs",
6 "sentAt": "2026-10-29T06:03:07.903Z",
7 "run": {
8 "id": "5d2f7a14-9c3b-4e68-8f21-b7c4a0e19d36",
9 "trackerId": "b13a7c58-02d4-4e9f-8a61-5f3c9e0d2b74",
10 "projectId": "2738158a-08fd-4fc4-912d-43d4c328bc10",
11 "source": "job_postings",
12 "completedAt": "2026-10-29T06:02:12.903Z",
13 "competitor": {
14 "name": "Acme",
15 "domain": "acme.com",
16 "subjectKey": "jobs:acme.com"
17 }
18 },
19 "pageSize": 100,
20 "events": {
21 "jobs.new": {
22 "total": 1,
23 "totalPages": 1,
24 "firstPageToken": "<page token: jobs.new, page 1>"
25 },
26 "jobs.updated": {
27 "total": 1,
28 "totalPages": 1,
29 "firstPageToken": "<page token: jobs.updated, page 1>"
30 },
31 "jobs.removed": {
32 "total": 1,
33 "totalPages": 1,
34 "firstPageToken": "<page token: jobs.removed, page 1>"
35 }
36 }
37}

Every event in the pages has the event shape, version 1. The page All event types lists each sub-type with its platform field.

Read the events

bash
1curl "$SIGNALS_HOST/api/public/v1/events?pageToken=$TOKEN" \
2 -H "x-api-key: $SIGNALS_API_KEY"

The response holds up to 100 events in items and a nextPageToken. Keep calling with nextPageToken until it is null. Read a run's events has the full response, the errors and a loop you can copy.

Pages vs the pull API

Page items have the event shape (eventId, eventType, adsResponse, …). The pull API without pageToken returns signal.v1. The page Pull API and keys maps one to the other.

Which events you get

Each webhook has four filters. An empty filter means "everything". Filters combine with AND: an event must match all four. They apply to the counts in the POST and to the events in the pages. For example, a webhook filtered to google_ad gets ads.new with the Google ads only: total counts them and the pages hold only them.

FilterMatches onExample
EventsEvent family (ads, reviews, social_post, mention, news, website, jobs, competitor) and sub-type (google_ad, g2_review, …)Only g2_review and trustpilot_review
ActionsThe event's action: new, updated or removedOnly removed, to hear about closed jobs
CompetitorsThe competitor's domainOnly hubspot.com
ProjectsThe project the tracker belongs toOnly your "EMEA" project

Picking a whole family sends every sub-type of it. Picking sub-types narrows within a family. If no event of a run matches, Signals sends no POST for that run.

The event filter in the Add webhook dialog
The Events filter lists families and their sub-types.

When it arrives

A run finishes

A tracker checks its source and stores the events it found.

The dispatcher counts

A job runs every minute. For each active webhook, it counts the run's events that match the filters. A run with 0 matches is skipped.

Signals POSTs to your URL

One signed request per webhook and run, with the headers below. You have 10 seconds to answer.

Your server answers 2xx

Any 2xx marks the delivery as delivered. Anything else is retried. See Retries and failures.

You read the pages

With your API key, at your own pace, within 30 days.

Deliveries to one endpoint are sent one at a time, oldest run first. A slow endpoint delays the runs behind it.

Headers

Every request carries these headers.

HeaderExampleMeaning
Content-Typeapplication/jsonAlways JSON.
Signals-Event-Version3The body's version. Changes only on a breaking change.
Signals-Webhook-IdA UUID, one per deliveryThe body's webhookId. The same on every retry of the delivery.
Signals-Delivery-Attempt1The try number, 1 to 9.
Signals-Signaturet=<unix seconds>,v1=<hex>Timestamp and HMAC-SHA256 signature. See Verify signatures.
Signals-TesttrueOnly on test deliveries. Absent on real ones.

Route in no-code tools

Zapier, Clay and n8n can branch on a body key. Branch on eventType for the family, or on events["ads.new"].total for one event name, then call the page URL in an HTTP step.

Delivery guarantees

  • At least once. The same POST can arrive more than once, for example after a timeout where your server did receive it. Dedupe on webhookId.
  • Events are keyed by eventId. A replay sends the run again with a new webhookId, and you can read a page twice. Dedupe each event on eventId.
  • No ordering guarantee. Runs are sent oldest first, but a retried POST can arrive after a newer one. Inside a page, events are in sequence order. Sort by detectedAt if order matters across runs.
  • Pause keeps your place. After Resume or Re-enable, Signals sends the runs that finished in between. A new webhook only gets runs that finish after it was created. Use the pull API to load older history.
  • Redirects are not followed. Give Signals the final URL.
  • Free. Webhooks and their pages do not use credits.

LinkedIn engagement

LinkedIn engagement is never sent by webhook, and never posted to Slack. Read it with the pull API.