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
POSTper webhook and run,Content-Type: application/json - Body
- A run webhook (
version3): the run, and per event name itstotal,totalPagesandfirstPageToken. 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
webhookIdand each event oneventId - 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
You answer 2xx
You read the pages
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.
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": 128 },29 "firstPageToken": "<page token: ads.new, page 1>"30 },31 "ads.updated": {32 "total": 0,33 "totalPages": 0,34 "firstPageToken": null35 }36 }37}
versionSignals-Event-Version header.webhookIdSignals-Webhook-Id header. The same on every retry. Use it to drop a repeated POST.typeeventTypesentAtrunpageSize100.eventsads.new, jobs.removed, …). Every name is listed, also with total: 0. See Event names per family.idrunId.trackerIdprojectIdnull.sourceads, reviews, linkedin, x, instagram, linkedin_mentions, reddit_mentions, news, website, job_postings or competitor_discovery.completedAtcompetitor{ name: string, domain: string | null, subjectKey: string }, the same object as on each event. Always set on a real run.totaltotalPagestotal divided by 100, rounded up. 0 when total is 0.bySubEventoptionaltotal per subEventType, for example { "google_ad": 1, "meta_ad": 1 }. Only when the run mixes sub-types: ad networks or review portals.firstPageTokennull 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.
eventType | Tracker source | subEventType | new | updated | removed |
|---|---|---|---|---|---|
ads | ads | google_ad, meta_ad, linkedin_ad | ads.new | ads.updated | |
reviews | reviews | g2_review, trustpilot_review, capterra_review, trustpilot_de_review, capterra_de_review, boxil_review | reviews.new | reviews.updated | |
social_post | linkedin, x, instagram | linkedin_post, x_post, instagram_post | social.post | social.post_updated | |
mention | linkedin_mentions, reddit_mentions | linkedin_mention, reddit_mention | social.mention | social.mention_updated | |
news | news | news_article | news.article | news.article_updated | |
website | website | website_change | website.page_added | website.changed | |
jobs | job_postings | job_posting | jobs.new | jobs.updated | jobs.removed |
competitor | competitor_discovery | new_competitor | market.new_competitor | market.competitor_updated |
That is 8 families and 18 sub-types.
- Only job postings send a
removedname (jobs.removed, a closed job). The other trackers never emit one, so their run webhook has noremovedsection. website.page_addedis listed withtotal: 0. Today a website run only haswebsite.changed.bySubEventappears 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:
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
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.
| Filter | Matches on | Example |
|---|---|---|
| Events | Event family (ads, reviews, social_post, mention, news, website, jobs, competitor) and sub-type (google_ad, g2_review, …) | Only g2_review and trustpilot_review |
| Actions | The event's action: new, updated or removed | Only removed, to hear about closed jobs |
| Competitors | The competitor's domain | Only hubspot.com |
| Projects | The project the tracker belongs to | Only 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.
When it arrives
A run finishes
The dispatcher counts
0 matches is skipped.Signals POSTs to your URL
Your server answers 2xx
2xx marks the delivery as delivered. Anything else is retried. See Retries and failures.You read the pages
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.
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | Always JSON. |
Signals-Event-Version | 3 | The body's version. Changes only on a breaking change. |
Signals-Webhook-Id | A UUID, one per delivery | The body's webhookId. The same on every retry of the delivery. |
Signals-Delivery-Attempt | 1 | The try number, 1 to 9. |
Signals-Signature | t=<unix seconds>,v1=<hex> | Timestamp and HMAC-SHA256 signature. See Verify signatures. |
Signals-Test | true | Only 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 newwebhookId, and you can read a page twice. Dedupe each event oneventId. - No ordering guarantee. Runs are sent oldest first, but a retried POST can arrive after a newer one. Inside a page, events are in
sequenceorder. Sort bydetectedAtif 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.