For developers

Webhooks

Signed notifications in your own systems when posts, accounts or your team change, when a limit is near, and every day with your numbers.

Included in the Enterprise plan

How webhooks work

Rawwij sends an HTTPS POST to your endpoint when something happens in your workspace: a post is scheduled, publishes, fails or is cancelled; an account connects, disconnects or is about to expire; your team changes; a limit is near; or a day's analytics are ready.

  • Webhooks are part of the Enterprise plan.
  • Up to 5 endpoints per workspace. Each subscribes to the events it chooses.
  • Events are delivered within 5 minutes and every request is signed.
  • Everything in your workspace counts, whoever on your team did it.

Set up an endpoint

  1. Open Settings, then Webhooks.
  2. Add your endpoint's URL. It must be HTTPS and publicly reachable.
  3. Choose its events. You can change them later without changing the secret.
  4. Copy the secret when it appears. It is shown once and cannot be recovered.
  5. Press Send test. A sample of one of your events arrives within 5 minutes, marked "test": true.

What each request looks like

A POST with a JSON body and these headers:

  • X-Rawwij-Event the event type, for example post.published
  • X-Rawwij-Delivery the delivery id, the same value as id in the body
  • X-Rawwij-Timestamp when this attempt was sent, in Unix seconds
  • X-Rawwij-Signature v1= followed by the signature

Body

{
  "id": "0b6f3c1e-8a2d-4f7b-9c5e-1d4a7b2c9e80",
  "type": "post.published",
  "created_at": "2026-10-01T09:01:12.000Z",
  "test": false,
  "data": {
    "post_id": "6f1c2a8e-4b1d-4c55-9a0e-2d7b3f9c1a42",
    "status": "published",
    "scheduled_at": "2026-10-01T09:00:00.000Z",
    "published_at": "2026-10-01T09:01:12.000Z",
    "error": null,
    "failed_count": 0,
    "accounts": [
      {
        "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
        "platform": "instagram",
        "username": "rawwij",
        "status": "published",
        "url": "https://www.instagram.com/p/DAbCdEfGhIj/",
        "platform_post_id": "18034567890123456",
        "error": null,
        "text": "Our autumn collection is here. Visit the store this weekend.",
        "media_type": "image",
        "media_count": 1
      }
    ]
  }
}
  • id stays the same when a delivery is retried or replayed. Use it to ignore duplicates.
  • created_at is when the event happened, not when this attempt was sent.
  • test is true only for deliveries sent with Send test.
  • platform is one of instagram, facebook, threads, tiktok, twitter (X), linkedin, youtube and telegram.

Verify the signature

Compute an HMAC-SHA256 of {timestamp}.{raw body} using your endpoint's secret, as hex. It must equal the value after v1= in X-Rawwij-Signature. Compare in constant time, and refuse a timestamp more than 5 minutes old.

Node.js

import crypto from 'node:crypto';

// rawBody: the request body exactly as received, before any JSON parsing.
export function verifyRawwij(rawBody, headers, secret) {
  const timestamp = String(headers['x-rawwij-timestamp'] ?? '');
  const signature = String(headers['x-rawwij-signature'] ?? '').replace(/^v1=/, '');

  // Refuse anything older than five minutes, so a captured request cannot be replayed.
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(signature, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python

import hashlib
import hmac
import time

# raw_body: the request body exactly as received, as bytes.
def verify_rawwij(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("X-Rawwij-Timestamp", "")
    signature = headers.get("X-Rawwij-Signature", "").removeprefix("v1=")

    # Refuse anything older than five minutes, so a captured request cannot be replayed.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Retries, failures and replay

  • Answer with any 2xx status within 10 seconds. Anything else is a failure, and redirects are not followed.
  • A failed delivery is retried 5 times, after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Then it stops.
  • After 20 failures in a row the endpoint is switched off, and the workspace owner gets a notification and an email. Turn it back on from Settings, Webhooks.
  • Every attempt appears in the delivery log with its status code, for 30 days. Replay re-sends the original payload with the same id.
  • If the workspace leaves the Enterprise plan, its endpoints stop receiving events. They stay listed so they can be deleted.

Every event

Posts

post.scheduledPost scheduled

A post is scheduled from the composer, the batch page or the iOS app. One event per scheduling, however many accounts it goes to.

data

{
  "post_id": "6f1c2a8e-4b1d-4c55-9a0e-2d7b3f9c1a42",
  "scheduled_at": "2026-10-01T09:00:00.000Z",
  "scheduled_by": "2a7e4c1b-9f3d-4b6a-8c2e-5d1f0a9b7c34",
  "accounts": [
    {
      "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
      "platform": "instagram",
      "username": "rawwij",
      "text": "Our autumn collection is here. Visit the store this weekend.",
      "media_type": "image",
      "media_count": 1
    },
    {
      "account_id": "c8a1f4e2-3d5b-4a9c-8e7f-6b2d1c9a0e51",
      "platform": "linkedin",
      "username": "Rawwij",
      "text": "Our autumn collection is here. Visit the store this weekend.",
      "media_type": "image",
      "media_count": 1
    }
  ]
}
post.publishedPost published

Every account a post was for has finished and at least one published. Carries each account's live link and post ID, and the error for any account that did not publish.

data

{
  "post_id": "6f1c2a8e-4b1d-4c55-9a0e-2d7b3f9c1a42",
  "status": "published",
  "scheduled_at": "2026-10-01T09:00:00.000Z",
  "published_at": "2026-10-01T09:01:12.000Z",
  "error": null,
  "failed_count": 0,
  "accounts": [
    {
      "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
      "platform": "instagram",
      "username": "rawwij",
      "status": "published",
      "url": "https://www.instagram.com/p/DAbCdEfGhIj/",
      "platform_post_id": "18034567890123456",
      "error": null,
      "text": "Our autumn collection is here. Visit the store this weekend.",
      "media_type": "image",
      "media_count": 1
    }
  ]
}
post.failedPost failed

Every account a post was for has finished and none published. Carries each account's error.

data

{
  "post_id": "6f1c2a8e-4b1d-4c55-9a0e-2d7b3f9c1a42",
  "status": "failed",
  "scheduled_at": "2026-10-01T09:00:00.000Z",
  "published_at": null,
  "error": "Facebook: You have been temporarily blocked from performing this action.",
  "failed_count": 1,
  "accounts": [
    {
      "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
      "platform": "facebook",
      "username": "Rawwij",
      "status": "failed",
      "url": null,
      "platform_post_id": null,
      "error": "You have been temporarily blocked from performing this action.",
      "text": "Our autumn collection is here. Visit the store this weekend.",
      "media_type": "image",
      "media_count": 1
    }
  ]
}
post.cancelledPost cancelled

Someone cancels a scheduled post, on the web or in the iOS app.

data

{
  "post_id": "6f1c2a8e-4b1d-4c55-9a0e-2d7b3f9c1a42",
  "cancelled_by": "2a7e4c1b-9f3d-4b6a-8c2e-5d1f0a9b7c34",
  "post_status": "draft",
  "accounts": [
    {
      "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
      "platform": "instagram",
      "username": "rawwij"
    }
  ],
  "still_publishing": 0,
  "already_published": 0
}

Accounts

account.connectedAccount connected

An account is connected, or an account the workspace already holds is reconnected.

data

{
  "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
  "platform": "tiktok",
  "username": "rawwij",
  "reconnected": false,
  "connected_by": "2a7e4c1b-9f3d-4b6a-8c2e-5d1f0a9b7c34"
}
account.disconnectedAccount disconnected

An account is disconnected in Rawwij, or on the platform itself: Rawwij removed from the account's settings, a data deletion request, or access revoked.

data

{
  "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
  "platform": "facebook",
  "username": "Rawwij",
  "source": "platform",
  "reason": "app_removed_on_platform",
  "disconnected_by": null
}
account.token_expiringAccount about to expire

An account's connection ends within 7 days and cannot renew itself (LinkedIn, every 60 days). Sent once for each expiry.

data

{
  "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
  "platform": "linkedin",
  "username": "Rawwij",
  "expires_at": "2026-10-05T11:00:00.000Z",
  "days_left": 6
}
account.expiredAccount expired

An account's connection has stopped working and needs reconnecting. Publishing to it is paused.

data

{
  "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
  "platform": "youtube",
  "username": "Rawwij",
  "reason": "Token has been expired or revoked."
}

Team

team.member_invitedMember invited

Someone is invited to the workspace, or an invite is sent again.

data

{
  "team_id": "4e8c1a6d-2b9f-4d7e-b3a1-7c5e0d2f9b16",
  "invite_id": "7b2f9e4c-6a1d-4c8b-9e3f-2d5a8c1b0e67",
  "email": "sara@example.com",
  "role": "editor",
  "invited_by": "2a7e4c1b-9f3d-4b6a-8c2e-5d1f0a9b7c34",
  "expires_at": "2026-10-08T09:00:00.000Z",
  "resent": false
}
team.member_joinedMember joined

Someone accepts an invite and joins the workspace.

data

{
  "team_id": "4e8c1a6d-2b9f-4d7e-b3a1-7c5e0d2f9b16",
  "user_id": "9d4b2e7a-1c8f-4e3b-a6d5-0f2c7b1e4a98",
  "email": "sara@example.com",
  "role": "editor"
}
team.member_role_changedRole changed

A member's role changes. An ownership transfer sends two: one for the new owner, one for the old.

data

{
  "team_id": "4e8c1a6d-2b9f-4d7e-b3a1-7c5e0d2f9b16",
  "user_id": "9d4b2e7a-1c8f-4e3b-a6d5-0f2c7b1e4a98",
  "email": "sara@example.com",
  "old_role": "editor",
  "new_role": "admin",
  "changed_by": "2a7e4c1b-9f3d-4b6a-8c2e-5d1f0a9b7c34"
}
team.member_removedMember removed or left

A member is removed, leaves on their own, or is removed by the system, for example when a plan change lowers the seat count.

data

{
  "team_id": "4e8c1a6d-2b9f-4d7e-b3a1-7c5e0d2f9b16",
  "user_id": "9d4b2e7a-1c8f-4e3b-a6d5-0f2c7b1e4a98",
  "email": "sara@example.com",
  "role": "editor",
  "reason": "removed",
  "removed_by": "2a7e4c1b-9f3d-4b6a-8c2e-5d1f0a9b7c34"
}

Usage

usage.thresholdUsage at 80% and 100%

The workspace reaches 80% or 100% of its monthly X posts or its storage, or a member reaches 80% or 100% of their monthly AI captions. Once per level per period, checked every hour.

data

{
  "metric": "x_posts",
  "threshold": 80,
  "used": 320,
  "limit": 400,
  "unit": "posts",
  "user_id": null,
  "period_start": "2026-10-01T00:00:00.000Z",
  "period_end": "2026-11-01T00:00:00.000Z"
}

Analytics

analytics.dailyDaily analytics

Once a day, after 07:00 UTC: yesterday's followers, engagement, impressions and reach for every connected account that reports them. A number the platform does not report is null, never 0. YouTube, LinkedIn and Telegram are not included.

data

{
  "date": "2026-09-30",
  "accounts": [
    {
      "account_id": "b3e9d1f0-7a2c-4e8b-9c4d-1f6a5e2b8c73",
      "platform": "instagram",
      "username": "rawwij",
      "followers": 12480,
      "engagement": 922,
      "impressions": 18230,
      "reach": 9412,
      "synced_at": "2026-10-01T00:15:40.000Z"
    },
    {
      "account_id": "c8a1f4e2-3d5b-4a9c-8e7f-6b2d1c9a0e51",
      "platform": "tiktok",
      "username": "rawwij",
      "followers": 3310,
      "engagement": 1343,
      "impressions": 40210,
      "reach": null,
      "synced_at": "2026-10-01T00:15:52.000Z"
    }
  ]
}

Good practice

  • Answer 2xx first and do the work afterwards, so a slow job does not look like a failure.
  • Deduplicate on id. A retry after a timeout can deliver an event you already processed.
  • Do not rely on order. Use created_at to put events in sequence.
  • Verify every signature, and treat the secret like a password.
  • Ignore fields you do not know. New fields may be added; existing ones will not change meaning.