Webhooks

Event types, delivery headers, signature verification with Node.js and Python code, retries and idempotency.

Webhooks tell your server when something changes, so you do not poll. Siar sends an HTTPS POST with a JSON body to each endpoint subscribed to the event.

Create an endpoint

Organization owners and admins manage webhooks from the dashboard. API keys cannot create or edit them. You can have up to 5 endpoints; each has its own event list and its own secret.

# Webhooks are managed by organization owners and admins from the dashboard.
# API keys cannot create or change them. The underlying request is:
POST https://api.siaran.my/api/v1/webhook
{
  "url": "https://yourapp.example/webhooks/siar",
  "events": ["post.published", "comment.received", "social-account.updated"]
}
# → { "id": "...", "url": "...", "events": [...], "active": true, "secret": "..." }
# The secret is returned once. It signs every delivery.

The endpoint must be an HTTPS URL that answers within 15 seconds. Siar does not follow redirects.

The request

Headers:

HeaderValue
x-siar-eventThe event type.
x-siar-deliveryA unique id for this delivery.
x-siar-timestampUnix time in seconds when it was signed.
x-siar-signatureHex HMAC-SHA256 of timestamp + "." + rawBody with your secret.
user-agentSiar-Webhooks/1.0

Body:

{
  "id": "evt_...",
  "type": "post.published",
  "createdAt": "2026-11-02T09:00:04.120Z",
  "organizationId": "ORG_ID",
  "data": { }
}

id is stable across redeliveries of the same event; data holds the object the event is about.

Verify the signature

Compute the HMAC over the raw bytes of the body, before any JSON parsing, using the secret from when you created the endpoint. Compare in constant time, and reject timestamps more than five minutes from now to stop replays.

import { createHmac, timingSafeEqual } from 'node:crypto';

// Use the raw request body exactly as received, before any JSON parsing.
export function verifySiarWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers['x-siar-timestamp'];
  const signature = headers['x-siar-signature'];
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(String(signature), 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express example: keep the raw body for this route.
app.post('/webhooks/siar', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifySiarWebhook(raw, req.headers, process.env.SIAR_WEBHOOK_SECRET)) return res.sendStatus(401);
  const event = JSON.parse(raw);
  // Deduplicate on the x-siar-delivery header or event.id, then handle event.type.
  res.sendStatus(200);
});

Events

EventWhen it fires
post.publishedA post finished publishing on a network, or failed for good. Sent once per post per network.
comment.publishedA comment or reply you created through Siar was published (or failed).
team.createdA team was created.
team.updatedA team was renamed or its settings changed.
team.deletedA team was deleted.
social-account.createdAn account was connected to a team.
social-account.updatedAn account changed: channel picked, profile refreshed, or it needs reconnecting.
social-account.deletedAn account was disconnected.
credit.low-balanceThe X credit balance fell below the threshold you set.
credit.exhaustedThe X credit balance reached zero. X actions stop until you top up; other networks keep publishing.
comment.receivedSomeone commented on a post, and Siar picked it up from the network.
conversation.createdA new direct-message conversation started.
conversation.updatedA conversation was archived, reopened or otherwise changed.
conversation.message.createdA message was sent or received in a conversation.
conversation.message.updatedA message changed (reaction, edit, delivery state).
conversation.read.updatedThe read state of a conversation changed.

Delivery and retries

A 2xx response means delivered. Anything else, or a timeout, schedules a retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, after which the delivery is marked failed. Order is not guaranteed and a delivery can arrive more than once, so:

  • Return 200 quickly and do the work afterwards.
  • Make your handler idempotent: deduplicate on x-siar-delivery or the event id.
  • Treat the event as a prompt to read current state when order matters.

Operate

  • GET /webhook/{id}/deliveries lists deliveries with response codes and timings.
  • POST /webhook/{id}/deliveries/{deliveryId}/resend sends one again.
  • POST /webhook/{id}/test sends a test event.
  • POST /webhook/{id}/rotate-secret issues a new secret; update your receiver, then confirm with a test.

See the webhook reference for the exact schemas.