Skip to content

Webhooks

Instead of polling GET /api/posts, register an endpoint and Pubrivo sends it an event each time a Variant publishes or fails.

Managing webhooks is a permission of its own, webhooks:manage:

  • An API key has it if Also allow managing webhooks was ticked when it was created.
  • An Authorized app has it if you allowed managing webhooks when you approved it.
Terminal window
curl https://app.pubrivo.com/api/webhooks \
-H "Authorization: Bearer $PUBRIVO_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/pubrivo", "events": ["variant.published", "variant.failed"] }'

The answer is a 201 carrying secret, which starts whsec_. It is shown once. Keep it with your receiver; you need it to check each event came from Pubrivo.

  • The URL must be https on the standard port, reach the public internet, and not redirect. An address inside a private network, on another port, or one that answers with a redirect is refused with 422 and code unsafe_url.
  • A Workspace can have up to 10 endpoints.
  • GET /api/webhooks lists them, each with its latest deliveries. DELETE /api/webhooks/{id} stops sending to one. Settings › API keys shows the same list, and why a delivery failed.
  • An endpoint stops when the Member whose key or app registered it leaves the Workspace.
Event When
variant.published A Variant is live on its Network. permalink links to it.
variant.failed A Variant failed. failureReason says what you can do; failureDetail is the Network’s own words.

Each event is a POST with a JSON body:

{
"id": "0b7e6a4c-3f1d-4b8e-9c2a-5d6e7f8a9b0c",
"type": "variant.published",
"createdAt": "2026-10-14T07:00:04.512Z",
"data": {
"variant": { "id": "…", "status": "published", "permalink": "https://www.threads.net/…" },
"post": { "id": "…", "text": "Fresh out of the oven.", "variants": ["…"] }
}
}

data.post is the Post exactly as GET /api/posts/{id} reads it at that moment, and data.variant is the one of its Variants the event is about. The full shape is in the reference.

Every event carries a Pubrivo-Signature header:

Pubrivo-Signature: t=1791961204,v1=5f2b…c9

v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your secret. Compute it over the body as it arrived, before parsing it, compare in constant time, and refuse a t more than five minutes old — the timestamp is inside what is signed, so an old event replayed is refused too.

import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')))
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
return (
fresh &&
typeof parts.v1 === 'string' &&
parts.v1.length === expected.length &&
timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
)
}
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) <= tolerance
return fresh and hmac.compare_digest(parts.get("v1", ""), expected)
  • Answer 2xx within 10 seconds. Anything else — another status, a timeout, no answer — is tried again: after 30 seconds, then doubling, never more than an hour apart, for 24 hours. Then the delivery is marked failed and is not tried again.
  • Redirects are not followed. Register the final address.
  • At least once. A delivery can arrive twice — say, when your answer was lost on the way back. id (also in the Pubrivo-Event-Id header) is the same each time: dedupe on it.
  • Order is not guaranteed. Read createdAt, or fetch the Post, when order matters.
  • Deliveries are kept for 30 days after they succeed or give up, then deleted.