Webhook events
Webhooks tell your app when something happens in Refract, as it happens. Admins add endpoints in Settings › API and agents › For developers › Webhooks: a URL, the events it wants, and the workspaces they come from (all of them by default, including ones added later).
Events
| Event | Sent when |
|---|---|
post.versions_ready |
Every version of a post started from a note is written |
post.version_approved |
Someone approved a version of a post started from a note |
post.queued |
A post was scheduled or added to the queue |
post.published |
A post went out on an account (one event per account) |
post.failed |
A post failed on one or more accounts (one event per failed account) |
post.edited |
An edit of a published post was saved to an account's network from Refract (one event per account): post, account, permalink, text, edited_at, by |
post.removed |
A published post was removed from an account's network from Refract (one event per account): post, account, permalink, removed_at, by |
approval.requested |
Someone sent a post, or an edit of a published post, for approval: kind is post or edit; an edit adds account and text |
account.needs_reconnect |
An account needs signing in again |
account.disconnected |
An account was removed from a workspace |
Send a test event in an endpoint's deliveries drawer sends a test event to that endpoint alone.
The request
Each event is a POST with a JSON body:
POST /refract-events HTTP/1.1
Content-Type: application/json
User-Agent: Refract-Webhooks/1.0 (+https://refract.social/docs/webhooks)
Refract-Event: post.published
Refract-Delivery: 0199e2d4-7a1b-7c2d-8e3f-4a5b6c7d8e9f
Refract-Signature: t=1792058520,v1=5f2b…c9a1
Every event has the same envelope; data depends on the event:
{
"id": "evt_0199e2d47a1b7c2d8e3f4a5b6c7d8e9f",
"type": "post.published",
"created_at": "2026-10-14T09:02:00+00:00",
"workspace": { "id": "0199e2a4-…", "slug": "kolo-coffee", "name": "Kolo Coffee" },
"data": {
"post": {
"id": "0199e2b0-…",
"status": "published",
"text": "Fresh Guji on the bar today.",
"scheduled_at": "2026-10-14T09:00:00+00:00",
"url": "https://refract.socialw/kolo-coffee/posts?post=0199e2b0-…",
"source": null
},
"account": { "id": "0199e2a5-…", "platform": "instagram", "handle": "kolocoffee", "name": "Kolo Coffee" },
"permalink": "https://www.instagram.com/p/…",
"short_link": "https://…",
"published_at": "2026-10-14T09:02:00+00:00"
}
}
data.post is the same in every post.* event. source is null, or for a post that came from a WordPress blog post {"type": "wordpress", "account_id", "external_id", "url", "title"}: the site's account, the blog post's ID on the site, its link and its title. post.published has short_link: the short link in that account's text, or null when its links weren't shortened.
post.failed has the failed account, its error (code, a plain message, and fix, the label of the button that fixes it in Refract, or null), every failed_accounts of the post so far, and failed_at:
"data": {
"post": { "id": "0199e2b0-…", "status": "failed", "text": "…", "scheduled_at": "…", "url": "…", "source": null },
"account": { "id": "…", "platform": "tiktok", "handle": "kolocoffee", "name": "Kolo Coffee" },
"error": { "code": "unsupported_media", "message": "TikTok can't post this kind of file (image/gif).", "fix": null },
"failed_accounts": [{ "id": "…", "platform": "tiktok", "handle": "kolocoffee", "name": "Kolo Coffee" }],
"failed_at": "2026-10-14T09:00:41+00:00"
}
approval.requested has the post, who sent it (requested_by: id and name) and the time it should go out (requested_for). account.needs_reconnect has the account and since. account.disconnected has the account.
post.versions_ready has one entry per account in versions; accounts on the same network share a version id. status is one of the statuses in the versions document:
"data": {
"post": { "id": "0199e2b0-…", "status": "draft", "text": "…", "scheduled_at": null, "url": "…", "source": { "type": "wordpress", "account_id": "…", "external_id": "123", "url": "https://kolo.coffee/huila", "title": "Our autumn Huila is here" } },
"versions": [{ "id": "0199e2b1-…", "account_id": "…", "network": "instagram", "status": "needs_ok" }]
}
post.version_approved has the version (id, network, status approved or edited, text and the account_ids it goes to) and approved_by (id and name, or null when a client approved by link). post.queued has accounts, one entry per account still to post, each with its account and scheduled_for.
Answer with any 2xx status within 10 seconds; Refract doesn't follow redirects. Use id to recognise an event you already handled: a retried delivery sends the same body again.
Verifying signatures
Every request is signed with the endpoint's signing secret (whsec_…, shown once when you add the endpoint). The Refract-Signature header holds a timestamp t and one or more v1 signatures: the hex HMAC-SHA256 of "{t}.{raw body}" with the secret.
To verify a request:
- Take
tand everyv1from the header. - Compute the HMAC-SHA256 of
t, a full stop and the raw request body (the bytes as received, before parsing), keyed with your secret. - Accept the request if it equals any of the
v1values (compare in constant time), andtis within 5 minutes of now.
PHP:
function refractSignatureIsValid(string $body, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$timestamp = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$valid = refractSignatureIsValid(file_get_contents('php://input'), $_SERVER['HTTP_REFRACT_SIGNATURE'] ?? '', getenv('REFRACT_WEBHOOK_SECRET'));
Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function refractSignatureIsValid(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(',').map((part) => part.trim().split('='));
const timestamp = Number(parts.find(([key]) => key === 't')?.[1]);
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false;
}
const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'));
return signatures.some((signature) => signature.length === expected.length && timingSafeEqual(Buffer.from(signature), expected));
}
Rolling the secret
Roll secret in the deliveries drawer gives the endpoint a new secret, shown once. For the next 24 hours every request carries two v1 signatures, one with the new secret and one with the old, so a receiver holding either keeps verifying while you swap the secret in. After that, only the new secret signs.
Retries and pausing
A delivery that doesn't get a 2xx answer is tried again 6 more times over about 24 hours: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours. Retry now in the drawer tries one at once.
If all 7 attempts of a delivery fail, Refract pauses the endpoint and emails whoever added it. While an endpoint is paused (by Refract or with Pause), new events are kept and sent when you Resume it. Deliveries, with their request and response bodies, are kept for 7 days.
Addresses Refract sends to
Endpoint URLs must use https (plain http only outside production) on the standard port, and resolve to public addresses: private, loopback and link-local addresses are refused, when the endpoint is added and again before every attempt.
WordPress sites
A WordPress site connected with the Refract plugin gets events at its own endpoint, signed with a secret the site holds. The site manages that endpoint: it isn't listed in Settings › API and agents › For developers › Webhooks and can't be changed there. It gets post.versions_ready, post.version_approved, post.queued, post.published and post.failed only for posts that came from that site, and account.disconnected of its workspace. It doesn't get post.edited or post.removed: Refract edits and removes the site's own copy through the plugin directly. Refract doesn't pause it when deliveries fail, and removing the site removes it.