Refract docsBack to Refract

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:

  1. Take t and every v1 from the header.
  2. Compute the HMAC-SHA256 of t, a full stop and the raw request body (the bytes as received, before parsing), keyed with your secret.
  3. Accept the request if it equals any of the v1 values (compare in constant time), and t is 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.