Polling events for automations
Poll GET /v1/events to react when a post goes out, a post fails, a draft is ready or a post takes off in your niche.
Postel has no webhooks. Your automation polls GET /v1/events every few minutes and acts on events it hasn't seen before. This is how the Zapier, n8n and Make apps work too.
| Parameter | Type | Default | Description |
|---|---|---|---|
type | string | required | The event type. One type per request. |
since | string | ISO 8601 time with a timezone. Only events after it. | |
before | string | ISO 8601 time with a timezone. Only events at or before it, to page back. | |
limit | integer | 25 | Events per page, 1 to 100. |
Events come newest first.
Event types
| Type | Fires when | Payload | Plans |
|---|---|---|---|
post.published | A post went out. Replies Postel sends for you are left out. | post | Every plan |
post.failed | A network refused a post, or part of a thread. | post, with failure set | Every plan |
draft.ready | Postel wrote a draft for you that is waiting in Review. | draft | Professional, Team and Enterprise |
viral.new | Postel found a post that took off in your niche, in For you. | viralPost | Professional, Team and Enterprise |
On plans without For you, draft.ready and viral.new return an empty list. They don't return an error.
The event object
| Field | Type | Description |
|---|---|---|
id | string | Unique per occurrence. Dedupe on it. |
type | string | One of the types above. |
occurredAt | string | When it happened, UTC ISO 8601. |
post | object or null | For post.published and post.failed. A post. |
draft | object or null | For draft.ready. See below. |
viralPost | object or null | For viral.new. A viral post with an extra fit field. |
draft has id, platform, text, writtenAt, plannedFor (the time Postel suggests, or null) and reviewUrl, the link to review, edit and schedule it in Postel.
viralPost.fit is a number from 0 to 1: how well the post fits your niche, as Postel scored it.
{
"events": [
{
"id": "post.published:cmhjz4w2p0001l708xq3kd9vb",
"type": "post.published",
"occurredAt": "2026-12-01T08:30:03.000Z",
"post": {
"id": "cmhjz4w2p0001l708xq3kd9vb",
"platform": "linkedin",
"status": "published",
"text": "We shipped our public API today. Three things we learned writing the docs first.",
"thread": [
"We shipped our public API today. Three things we learned writing the docs first."
],
"firstComment": null,
"mediaCount": 0,
"scheduledAt": "2026-12-01T08:30:00.000Z",
"publishedAt": "2026-12-01T08:30:03.000Z",
"url": "https://www.linkedin.com/feed/update/urn:li:share:7268000123456789012/",
"failure": null,
"createdAt": "2026-11-04T13:05:12.481Z",
"updatedAt": "2026-12-01T08:30:03.000Z"
},
"draft": null,
"viralPost": null
}
]
}Ids are stable
The same occurrence always has the same id, so you can store ids you've handled and skip them next time.
post.publishedfires once per post.post.failedfires once per failure. A post that fails, gets fixed and fails again has two events with different ids.draft.readyfires again when Postel rewrites a draft.viral.newfires once per post Postel found for you.
Don't parse ids. Their format can change.
A poll loop
Every few minutes:
- Ask for events
sincea minute before the newestoccurredAtyou've handled. The overlap catches events that land late. - If the page is full, page back with
beforeset to the oldestoccurredAton the page, until a page comes back short. - Skip every
idyou've already handled. Handle the rest, oldest first. - Remember the newest
occurredAtand the ids you handled.
const API = "https://www.postel.app/api/v1/events";
const headers = { Authorization: `Bearer ${process.env.POSTEL_API_KEY}` };
const LIMIT = 100;
// Load these from your own storage between runs
let newest = null;
const seen = new Set();
async function fetchPage(type, since, before) {
const url = new URL(API);
url.searchParams.set("type", type);
url.searchParams.set("limit", String(LIMIT));
if (since) url.searchParams.set("since", since);
if (before) url.searchParams.set("before", before);
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).code}`);
return (await res.json()).events;
}
async function poll(type, handle) {
const since = newest
? new Date(new Date(newest).getTime() - 60_000).toISOString()
: undefined;
const events = [];
let before;
for (let page = 0; page < 10; page++) {
const batch = await fetchPage(type, since, before);
events.push(...batch);
if (batch.length < LIMIT) break;
before = batch.at(-1).occurredAt;
}
const fresh = events
.filter((event) => !seen.has(event.id))
.filter((event, i, all) => all.findIndex((e) => e.id === event.id) === i)
.reverse();
for (const event of fresh) {
await handle(event);
seen.add(event.id);
if (!newest || event.occurredAt > newest) newest = event.occurredAt;
}
}
await poll("post.published", async (event) => {
console.log("Published:", event.post.url);
});Keep the set of seen ids bounded, for example the last 1,000. With the one-minute overlap, older ids never come back.
How often to poll
Every 1 to 15 minutes is plenty. Each poll is one request per event type, and every key has 60 requests a minute.
React to failures
A post.failed event carries the post with its failure. Most failures put the post back in drafts. Fix the cause, often an expired connection, then schedule it again.
If failure.partlyPublished is true, part of a thread is already live. Don't resend it. See A partly published thread.