SteveSignalsDocs

Webhooks

Read a run's events

Page through a run's events with GET /api/public/v1/events?pageToken=: 100 full events per page, nextPageToken until null, the errors and a loop you can copy.

A run webhook carries counts and one firstPageToken per event name. The events themselves come from the API, 100 per page. This page shows the request, the response, the errors and a complete loop.

Request
GET /api/public/v1/events?pageToken=…
Auth
An API key of the same organization, in the x-api-key header
Page size
Up to 100 events, oldest sequence first
Next page
nextPageToken. null on the last page
Token lifetime
30 days after the POST was sent
Stable
The same token returns the same page while your webhook's filters are unchanged

Request

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

SIGNALS_HOST is the address of the app you sign in to. URL-encode the token if your HTTP client does not do it for you.

When pageToken is present, the other query parameters (cursor, limit, since, projectId, trackerId, source) are ignored. The token already names the run, the event name, the page and your webhook. Every page applies your webhook's filters as they are when you read it. While the filters are unchanged, a page holds only the events that were counted in the POST. If you edit the filters after the POST, the pages follow the new filters: total, totalPages and the events can differ from the counts in the POST.

Response

GET /api/public/v1/events?pageToken=… · jobs.new (Acme)Sample with the exact shape (no real run yet)
json
1{
2 "run": {
3 "id": "5d2f7a14-9c3b-4e68-8f21-b7c4a0e19d36"
4 },
5 "eventName": "jobs.new",
6 "total": 1,
7 "page": 1,
8 "totalPages": 1,
9 "items": [
10 {
11 "version": 1,
12 "eventId": "3f1c9a52-6b0e-4c3a-9d41-0a7d2b8e5f10",
13 "eventType": "jobs",
14 "subEventType": "job_posting",
15 "action": "new",
16 "signalType": "jobs.new",
17 "source": "job_postings",
18 "orgId": "org_3KikADUjsyXrUJ4RJhWpwcp98Yf",
19 "trackerId": "b13a7c58-02d4-4e9f-8a61-5f3c9e0d2b74",
20 "projectId": "2738158a-08fd-4fc4-912d-43d4c328bc10",
21 "runId": "5d2f7a14-9c3b-4e68-8f21-b7c4a0e19d36",
22 "competitor": {
23 "name": "Acme",
24 "domain": "acme.com",
25 "subjectKey": "jobs:acme.com"
26 },
27 "occurredAt": "2026-10-27T09:12:00.000Z",
28 "detectedAt": "2026-10-28T06:03:11.482Z",
29 "sequence": 301,
30 "itemKey": "job_postings-7421035",
31 "relevance": {
32 "status": "unjudged"
33 },
34 "jobResponse": {
35 "title": "Senior Product Marketing Manager",
36 "text": "Acme is hiring a Senior Product Marketing Manager to own positioning and launches for our analytics platform. You will work with product, sales and customer success to bring new features to market.",
37 "url": "https://boards.greenhouse.io/acme/jobs/7421035",
38 "author": null,
39 "media": [],
40 "metrics": null,
41 "status": "open",
42 "jobId": "7421035",
43 "atsType": "greenhouse",
44 "boardUrl": "https://boards.greenhouse.io/acme",
45 "department": "Marketing",
46 "team": "Product Marketing",
47 "category": "marketing",
48 "locations": [
49 {
50 "raw": "San Francisco, CA",
51 "countryCode": "US"
52 }
53 ],
54 "workplaceType": "hybrid",
55 "employmentType": "full_time",
56 "postedAt": "2026-10-27T09:12:00.000Z",
57 "applyUrl": "https://boards.greenhouse.io/acme/jobs/7421035",
58 "salary": {
59 "min": 170000,
60 "max": 210000,
61 "currency": "USD",
62 "period": "year",
63 "raw": "$170,000 - $210,000"
64 },
65 "changes": []
66 },
67 "metadata": {
68 "title": "Senior Product Marketing Manager",
69 "text": "Acme is hiring a Senior Product Marketing Manager to own positioning and launches for our analytics platform. You will work with product, sales and customer success to bring new features to market.",
70 "url": "https://boards.greenhouse.io/acme/jobs/7421035",
71 "author": null,
72 "media": [],
73 "metrics": null,
74 "status": "open",
75 "jobId": "7421035",
76 "atsType": "greenhouse",
77 "boardUrl": "https://boards.greenhouse.io/acme",
78 "department": "Marketing",
79 "team": "Product Marketing",
80 "category": "marketing",
81 "locations": [
82 {
83 "raw": "San Francisco, CA",
84 "countryCode": "US"
85 }
86 ],
87 "workplaceType": "hybrid",
88 "employmentType": "full_time",
89 "postedAt": "2026-10-27T09:12:00.000Z",
90 "applyUrl": "https://boards.greenhouse.io/acme/jobs/7421035",
91 "salary": {
92 "min": 170000,
93 "max": 210000,
94 "currency": "USD",
95 "period": "year",
96 "raw": "$170,000 - $210,000"
97 },
98 "changes": []
99 }
100 }
101 ],
102 "nextPageToken": null
103}
Events page
run
object
{ id: string }, the run of the webhook.
eventName
string
The event name of this page, for example jobs.new. One page holds one name.
total
number
How many events of this name match the webhook's current filters. The same as the POST's total unless the filters changed since.
page
number
This page's number, 1 to totalPages.
totalPages
number
total divided by 100, rounded up. The same as the POST's totalPages unless the filters changed since.
items
object[]
Up to 100 events, in sequence order. Each one has the event shape, version 1, with its own eventId.
nextPageToken
string|null
Pass it as pageToken for the next page. null on the last page.

A run with 101 events of one name has two pages: 100 events, then 1. A run with 5,000 has 50 pages.

Errors

The errors have the form { "error": "..." }.

StatusMeaningWhat to do
400The token is not valid: changed, cut short or not from Signals.Use the token exactly as Signals sent it.
401Missing, invalid or revoked API key.Check x-api-key.
403The key does not belong to a Signals organization.Use a key of the organization that owns the webhook.
404The run is not in your key's organization, the webhook was deleted, or the run's project was deleted.Use a key of the same organization. Skip runs of deleted webhooks and deleted projects.
410The token expired: it is more than 30 days old.Read the run through the pull API: GET /api/public/v1/events?trackerId=…&since=… with the run's trackerId and a since before it ran, then keep the items whose runId is the run's id. These are signal.v1 objects. A replay does not help: it only takes dead or failed deliveries, and the delivery log keeps them for 30 days.
429More than 30 requests per minute from one IP.Wait for Retry-After (60 seconds), then send the same token again.

A page is safe to fetch again

A token is stateless. If your worker crashes halfway, read the same page again with the same token. You get the same events (as long as the webhook's filters did not change), and your eventId check drops the ones you already stored.

The loop

Your endpoint stores the body and answers 200. A background job then walks every section:

Skip a repeated POST

If you already handled this webhookId, stop.

Walk each event name

For each entry in events whose firstPageToken is not null, start with that token.

Fetch a page

GET /api/public/v1/events?pageToken=…. On 429, wait Retry-After and try the same token again.

Store each event

Skip any eventId you already have.

Follow nextPageToken

Repeat with nextPageToken until it is null. Then mark the webhookId as done.

Give your storage a unique index on eventId, so an event you already stored is skipped.

Test deliveries

Send test event POSTs a run webhook with Signals-Test: true and one reviews.new event. Its token returns a sample G2 review, so you can run the whole loop before a real run arrives.