Webhooks
Get an HTTPS request from eksis when a post goes out, a comment or message arrives, or a channel needs attention.
A webhook endpoint is a URL on your server that eksis calls when something happens in the workspace. You stop asking GET /v1/posts/{postId} whether a post went out, and hear about it once it did.
When to use them
Use webhooks for anything you would otherwise poll for: a post published or failed on a channel, a new comment, a new direct message, or a channel whose connection broke. Keep one periodic read of the API anyway. Delivery is at least once and can be delayed by retries, so a nightly GET /v1/posts is a cheap way to catch anything your endpoint missed while it was down.
Webhooks come with the REST API, so they need the Starter plan or higher:
| Plan | Webhook endpoints |
|---|---|
| Free | None |
| Starter | 3 |
| Premium | 10 |
| Agency | 30 |
Add an endpoint
In the eksis app, open the workspace, go to API & MCP, and add an endpoint in the Webhooks section. Give it a URL, choose the events it should receive, and copy the signing secret. The secret is shown once.
You can do the same with POST /v1/webhooks. Managing webhooks needs a key with the admin role, because an endpoint keeps receiving workspace data after the key that created it is gone.
curl https://api.eksis.io/v1/webhooks \
-H "Authorization: Bearer $EKSIS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/eksis-webhooks",
"events": ["post.target.published", "post.target.failed"],
"description": "Publishing results"
}'The answer carries the endpoint and its secret, which starts with whsec_. Store it next to your other credentials.
The URL must use https and resolve to a public address. eksis refuses addresses on private networks (webhook.url_private) and does not follow redirects.
Events
An endpoint receives only the events it subscribed to. New event types are never added to an existing endpoint on their own, so a new type cannot surprise your handler.
| Event | Sent when |
|---|---|
post.target.published | One channel of a post finished publishing. |
post.target.failed | Publishing to one channel failed for good. Attempts that will be retried are not sent. |
comment.received | A new comment from someone else arrived. |
message.received | A direct message or a story reply arrived. data.message.kind tells them apart. |
channel.unhealthy | The connection to a channel stopped working, and its posts are held. |
channel.disconnected | A channel was disconnected from the workspace. |
webhook.test | You pressed Send test or called the test endpoint. It cannot be subscribed to. |
Which events a platform can produce is listed on its platform page.
The request
Every delivery is a POST with a JSON body:
{
"id": "evt_9f2c4d1a7b",
"type": "comment.received",
"createdAt": "2026-10-10T12:00:00.000Z",
"workspaceId": "WORKSPACE_ID",
"data": {
"id": "COMMENT_ID",
"text": "Is this available in blue?",
"status": "open",
"isHidden": false,
"channelId": "CHANNEL_ID",
"platform": "instagram",
"postId": "POST_ID",
"parentId": null,
"externalPostId": "17890000000000000",
"authorUsername": "someone",
"authorName": "Someone",
"createdAt": "2026-10-10T11:59:58.000Z"
}
}data has the same shape as the matching API resource, so code that reads GET /v1/comments can read this too. The page of each event shows its full data.
| Header | Value |
|---|---|
webhook-id | The event id, the same as id in the body. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | v1, followed by the base64 signature. During a secret rotation it holds two values separated by a space. |
eksis-event-type | The event type, so you can route before parsing the body. |
eksis-delivery-attempt | 1 for the first attempt, then counting up on retries. |
user-agent | eksis-webhooks/1.0 (+https://docs.eksis.io/en/api/webhooks) |
Verify the signature
eksis signs every request with the endpoint secret, following the Standard Webhooks format. Check the signature before you trust the body:
- Take the part of the secret after
whsec_and base64-decode it. That is the HMAC key. - Build the string
{webhook-id}.{webhook-timestamp}.{raw body}, using the body exactly as it arrived. - Compute HMAC-SHA256 of that string and base64-encode it.
- Compare it, in constant time, with each
v1,value inwebhook-signature. One match is enough. - Reject a
webhook-timestampmore than five minutes away from your clock, so an old request cannot be replayed.
Read the raw body before any JSON parser touches it. Re-serialized JSON rarely matches the signed bytes.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyEksisWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}The Standard Webhooks project also publishes verification libraries, among them standardwebhooks for JavaScript and for Python. They accept the eksis headers as they are.
Respond quickly
Answer with any 2xx status within 10 seconds. Anything else, a timeout, or a redirect counts as a failed attempt. If handling an event takes longer, store it, answer 2xx, and do the work afterwards.
Retries and duplicates
A failed attempt is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, and 12 hours, give or take 10 percent. That is nine attempts over about 23 hours.
- At least once. The same event can arrive more than once. Keep the
webhook-idvalues you have handled and skip repeats. - No ordering. Events can arrive out of order. When order matters, compare
createdAtor read the resource from the API.
An endpoint that keeps failing for 72 hours, with at least 10 failed attempts, is disabled. An endpoint that answers 410 Gone is disabled at once. The workspace owner and admins are told when this happens. Turn it back on in the app, or send PATCH /v1/webhooks/{webhookId} with "enabled": true.
Test and inspect
Send test in the app, or POST /v1/webhooks/{webhookId}/test, delivers a webhook.test event right away and shows how your endpoint answered. Test events are never retried and do not count toward disabling the endpoint.
Each endpoint keeps a log of its deliveries for 14 days: event, attempts, the status code your server returned, and how long it took. Read it in the app or with GET /v1/webhooks/{webhookId}/deliveries, and send a delivery again from the app after fixing your handler.
Rotate the secret
Rotate the secret from the app or with POST /v1/webhooks/{webhookId}/rotate-secret. The new secret is shown once. For the next 24 hours eksis signs with both the old and the new secret, so you can deploy the new one without dropping events.
Develop locally
eksis can only reach public HTTPS addresses. While you build the handler on your own machine, expose it through a tunnel such as cloudflared or ngrok, register the tunnel URL as an endpoint, and use Send test to try it.
Timing on each platform
post.target.* events are sent as soon as eksis knows the result. How fast comment.received follows a real comment depends on the platform: some platforms push new comments to eksis, others are checked on a schedule, so the event can come several minutes later. The platform specifications list how each one works.