Webhooks

Highlight created

A reader marked a passage of an article.

No user_id. A highlight is never attributed to a named reader anywhere in Commune, so this payload carries the same opaque owner_key GET /highlights/{highlight} does. It is the one topic whose payload cannot be resolved back to a person.

Fires on a new highlight only. Re-marking the same span of the same article is silent. So is attaching a chat message to a highlight that already exists, which is how a passage becomes a discussion; that fires message.created for the message instead.

A highlight created together with its discussion carries data.message_id from the start. One created on its own carries null and never gains a value on this topic.

A passage of a tag-scoped article can only be marked by someone in its audience.

Delivered as a single HTTPS POST to the consumer's registered endpoint, with the message as the JSON request body. Respond 2xx to acknowledge. Anything else, or a timeout, is retried with backoff, so acknowledge fast and do the work afterwards.

postWebhookhighlight.created

Headers

Commune-Versionstring required
Example:2026-08-26

API version the payload conforms to. Same value as api_version in the envelope.

Commune-Event-Idstring uuid required

Same value as id in the envelope. The dedupe key.

Commune-Event-Typestring required

Same value as type in the envelope. The topic name.

Commune-Signaturestring required
Example:v0=8501d20988a42e76a6f63c4aaabbca745b9ad6333c254c1665b970a7e3c39541

HMAC-SHA256 over Commune-Timestamp as Unix seconds, a literal ., then the raw request body, keyed by the endpoint's signing secret. Lowercase hex, carried as v0= followed by one or more comma separated digests (more than one while a secret is being rotated). The header's timestamp is an RFC 3339 instant, so convert it to Unix seconds before signing. Verify against the raw bytes before parsing the JSON. The webhooks guide has the full procedure.

Commune-Timestampstring date-time required
Example:2026-08-26T09:32:14Z

When this delivery attempt was made, as an RFC 3339 instant in UTC. It differs on every retry, and it IS covered by the signature, in Unix seconds, so a stale or altered one can be rejected. Enforce a tolerance window of a few minutes and dedupe on the event id as well, since a retry inside that window is legitimate.

Commune-Delivery-Attemptinteger

1 on the first attempt, incremented on each retry. Not sent today: do not rely on it, and be idempotent regardless. The same number is readable after the fact as attempt on a DeliveryAttempt, so a consumer that needs to tell a retry from a first delivery can ask instead of being told.

Payload

idstring uuid required

Unique id for this event. Stable across redeliveries, so it is the dedupe key. Assigned when the state change is recorded rather than when the message is dispatched.

type'highlight.created' required

The topic name, identical to the channel address. Route on this.

api_versionstring required

The Commune-Version value this payload conforms to. Present on the message itself, not only on the request header, so an event persisted to a consumer's own store stays self-describing.

occurred_atstring date-time required

RFC 3339 timestamp of the state change, not of the delivery attempt. Use it to order events, since delivery order is not guaranteed.

newsletter_idstring uuid nullable required

The newsletter the change belongs to. This is the tenant boundary: a consumer only ever receives events for newsletters its credential can read. Null only for events that are genuinely not newsletter-scoped, which none of the topics Commune publishes currently are.

idempotency_keystring nullable required

The Idempotency-Key of the API write that caused this change, so a consumer can tie an event back to its own request and collapse the duplicates a retried write would otherwise produce.

This is how a consumer that writes recognises its own work. Keep the keys you send, and skip any event carrying one of them. You chose the value, so you know it before the event arrives. actor cannot do this job, because Commune's id for your credential is not something this API reports to you.

Null for every change that did not come in through this API, which is most of them: an edit made in Commune, an import, a scheduled job and a delivery result all have no originating request to key on.

It reaches every endpoint registered for the newsletter, not only the one that made the write, so choose opaque keys such as UUIDs rather than keys carrying your own business identifiers.

Example payload

{
  "id": "018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90",
  "type": "article.published",
  "api_version": "2026-08-26",
  "data": {
    "owner_key": "4f2a9c1e7b3d6a05"
  }
}

Response

The consumer accepted the delivery. Any 2xx acknowledges the message and it is not sent again.

Changes

Webhooks have no change history: a webhook is keyed by its event name rather than a path and method, so no recorded change can name it.