A reader liked an article, or took the like back. data.direction says
which: added when the like was given, removed when it was withdrawn.
One topic carries both directions, so subscribing once is enough to mirror the whole association. A consumer that heard a named person liked an article and never heard them take it back would keep acting on a claim the person withdrew.
data.reader names the person. The same actions are readable as
engagement records at
GET /newsletters/{newsletter}/events?event_type=like. A reader with no
Commune account cannot like an article, so unlike article.read this
field is null only for a deleted account.
data.like_count is the whole tally after the change, counted in the
same transaction that made it, and is the number
GET /articles/{article} reports on stats.likes. It is on the removal
as well as the addition, so a consumer that stores it never has to add
anything up and a consumer that missed a message is corrected by the
next one.
Fires on a real row change only. Liking an article this person already liked is silent, and so is unliking one they had not liked. Liking, unliking and liking again fires three times.
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.
Headers
-
API version the payload conforms to. Same value as
api_versionin the envelope. -
Same value as
idin the envelope. The dedupe key. -
Same value as
typein the envelope. The topic name. -
HMAC-SHA256 over
Commune-Timestampas Unix seconds, a literal., then the raw request body, keyed by the endpoint's signing secret. Lowercase hex, carried asv0=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. -
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.
-
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
attempton aDeliveryAttempt, so a consumer that needs to tell a retry from a first delivery can ask instead of being told.Minimum value is
1.
Body
Required
A reader liked an article, or took the like back.
-
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.
-
The topic name, identical to the channel address. Route on this.
Value is
article.liked. -
The
Commune-Versionvalue 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. -
RFC 3339 timestamp of the state change, not of the delivery attempt. Use it to order events, since delivery order is not guaranteed.
-
Who caused the change, and
nullwhen nobody outside Commune did.Populated on a change made through this API's write operations, and null on every other change: an edit a creator made in the product, an import arriving from a provider, a scheduled job, a delivery result reported by the sending provider. Null is therefore the common case and stays a legitimate value. Read it as "this change came in through the API under this credential", never as "nothing caused this".
It never names a person, only the credential.
It answers "did this change come in through the API, and under which credential". It is not the field to drop the echo of your own write with: use
idempotency_key, which you chose and therefore already know, whereasidhere is Commune's own identifier for your credential and no operation reports it back to you. A creator can read that identifier in Commune, but it changes when the credential is replaced, so matching on it puts the loop back silently after a rotation.Additional properties are NOT allowed.
-
The
Idempotency-Keyof 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.
actorcannot 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.
Maximum length is
255. -
Event-specific body. Narrowed by each concrete event schema below.
Responses
-
The consumer accepted the delivery. Any 2xx acknowledges the message and it is not sent again.
-
The consumer rejected the delivery. Handled exactly like a 5xx: the message is retried with backoff, because a rejection cannot be told apart from a consumer that is briefly misconfigured.
-
The consumer failed to handle the delivery. Retried with backoff. A timeout is the same case and is retried too.
{
"id": "018f2a93-1010-7000-8000-000000000041",
"type": "article.liked",
"api_version": "2026-08-26",
"occurred_at": "2026-08-26T20:02:14Z",
"newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
"actor": null,
"idempotency_key": null,
"data": {
"article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
"title": "What newsletters get wrong about community",
"url": "https://example.com/p/what-newsletters-get-wrong-about-community",
"reader": {
"user_id": "9f8e7d6c-5b4a-4392-8180-7f6e5d4c3b2a",
"username": "mara",
"display_name": "Mara Iversen",
"avatar_url": "https://example.com/avatars/mara.png"
},
"direction": "added",
"like_count": 18,
"changed_at": "2026-08-26T20:02:14Z"
}
}
{
"id": "018f2a93-2020-7000-8000-000000000042",
"type": "article.liked",
"api_version": "2026-08-26",
"occurred_at": "2026-08-26T20:41:55Z",
"newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
"actor": null,
"idempotency_key": null,
"data": {
"article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
"title": "What newsletters get wrong about community",
"url": "https://example.com/p/what-newsletters-get-wrong-about-community",
"reader": {
"user_id": "9f8e7d6c-5b4a-4392-8180-7f6e5d4c3b2a",
"username": "mara",
"display_name": "Mara Iversen",
"avatar_url": "https://example.com/avatars/mara.png"
},
"direction": "removed",
"like_count": 17,
"changed_at": "2026-08-26T20:41:55Z"
}
}