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-keyheader - Page size
- Up to 100 events, oldest
sequencefirst - Next page
nextPageToken.nullon 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
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
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": null103}
run{ id: string }, the run of the webhook.eventNamejobs.new. One page holds one name.totaltotal unless the filters changed since.page1 to totalPages.totalPagestotal divided by 100, rounded up. The same as the POST's totalPages unless the filters changed since.itemsnextPageTokenpageToken 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": "..." }.
| Status | Meaning | What to do |
|---|---|---|
400 | The token is not valid: changed, cut short or not from Signals. | Use the token exactly as Signals sent it. |
401 | Missing, invalid or revoked API key. | Check x-api-key. |
403 | The key does not belong to a Signals organization. | Use a key of the organization that owns the webhook. |
404 | The 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. |
410 | The 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. |
429 | More 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
webhookId, stop.Walk each event name
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
eventId you already have.Follow nextPageToken
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.