Import finished
A bulk ingest run finished. data.kind says which of the three it was.
articles: an import a creator started against their connected provider finished the batch they picked. imported_count is the number that actually landed.
subscribers: a pull of every active contact from the connected provider finished. An uploaded CSV ends the same way, with source: csv.
migration: the newsletter's esp became commune. That is the destructive step of the move off a provider, and after it Commune sends the newsletter itself.
This topic is an invalidation signal. An import writes many records at once and fans out into no per-record events: it does not emit one article.published or one subscriber.created for each. This is the single message that says a newsletter changed underneath you, so refetch on it.
Only a run that reached its end publishes here. A partial run still finishes and still fires, with a lower imported_count and no count of what it dropped. A run whose connection died mid-stream produces nothing, and there is no import.failed to pair with this.
There is no run identifier on this payload and no operation to look one up in. A run is identified by the newsletter, the kind and the source, and the thing to do with one is refetch.
Imports are idempotent and safe to re-run, so expect to see the same kind more than once for one newsletter.
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_version in the envelope.
Same value as id in the envelope. The dedupe key.
Same value as type in the envelope. The topic name.
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.
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 attempt on a DeliveryAttempt, so a consumer that needs to tell a retry from a first delivery can ask instead of being told.
Payload
Example payload
{
"id": "018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90",
"type": "article.published",
"api_version": "2026-08-26",
"data": {
"source": "kit"
}
}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.