logo
Schedulin Developers

For AI agents: a documentation index is available at /llms.txt. Every guide and example is also available as markdown by adding .md to its URL.

Webhooks

Instead of polling GET /v0/posts/{id} or GET /v0/studio/getGeneration, register a webhook endpoint and Schedulin will POST to your URL when things happen.

Events

EventFires when
post.publishedA post finished publishing to its platform
post.failedA publish attempt terminally failed
generation.completedAn AI image, video, or portrait generation finished rendering
generation.failedAn AI generation failed
social_account.connectedA social account was connected or reconnected — including by a client through a connect link

Register an endpoint

curl -X POST https://api.schedulin.app/v0/webhooks \
  -H "x-api-key: $SCHEDULIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/schedulin",
    "events": ["post.published", "post.failed"],
    "description": "Production notifications"
  }'
const endpoint = await client.webhooks.create({
  url: "https://example.com/hooks/schedulin",
  events: ["post.published", "post.failed"],
  description: "Production notifications",
});
endpoint = client.webhooks.create(
    url="https://example.com/hooks/schedulin",
    events=["post.published", "post.failed"],
    description="Production notifications",
)

The response includes the signing secret (whsec_...) once — store it. Subsequent reads return a masked value. Rotate it any time with POST /v0/webhooks/{id}/rotate-secret.

URLs must be public https URLs: hosts that resolve to private, loopback, or link-local addresses (localhost, 127.0.0.1, 10.x, 192.168.x, …) are rejected with 422. To test locally, expose your receiver through a tunnel (ngrok, Cloudflare Tunnel) and register that URL. Each organization can register up to 10 endpoints. OAuth apps need the webhooks:read / webhooks:write scopes.

Delivery format

Every delivery is a JSON POST with the same envelope; data depends on the event.

{
  "id": "d2f1…",
  "event": "post.published",
  "createdAt": "2026-07-28T15:04:05.000Z",
  "data": {
    "postId": "…",
    "status": "COMPLETED",
    "socialAccountId": "…",
    "url": "https://www.tiktok.com/@yourbrand/video/…",
    "externalId": "…",
    "errorMessage": null
  }
}

post.published / post.failed

FieldDescription
postIdThe post's id — fetch it with GET /v0/posts/{id}
statusCOMPLETED for post.published, FAILED for post.failed
socialAccountIdThe channel the post was published to
urlPublic URL of the published post, when the platform provides one (else null)
externalIdThe platform's id for the published post (else null)
errorMessageWhy publishing failed (post.failed), else null

generation.completed / generation.failed

{
  "generationId": "…",
  "type": "image",
  "status": "COMPLETED",
  "resultUrl": "https://r2.schedulin.app/…",
  "errorMessage": null
}

resultUrl is set when the generation completed; errorMessage when it failed. Fetch GET /v0/studio/getGeneration for the full record.

social_account.connected

Fires once per account, so connecting several Facebook Pages in one flow sends one delivery per Page.

{
  "organizationId": "…",
  "socialAccount": {
    "id": "…",
    "platform": "instagram",
    "username": "yourclient",
    "profileId": "…",
    "status": "connected"
  },
  "reconnected": false
}
FieldDescription
organizationIdThe workspace the account was connected to
socialAccount.idThe account's id — fetch it with GET /v0/social-accounts/{id}
socialAccount.platformThe platform slug, e.g. instagram, tiktok, facebook
socialAccount.usernameThe account's handle or display name on the platform (else null)
socialAccount.profileIdThe client profile the account belongs to — set when a client connected through a connect link (POST /v0/profiles/{id}/connect-link), else null
socialAccount.statusAlways connected
reconnectedtrue when an existing account was re-authorized, false for a new connection

ping

Sent only by POST /v0/webhooks/{id}/test:

{ "message": "Test delivery from Schedulin" }

Headers on every delivery:

HeaderMeaning
x-schedulin-eventThe event name
x-schedulin-delivery-idUnique delivery id, same as the body's id (use for idempotency)
x-schedulin-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Verifying signatures

The signature is an HMAC-SHA256 of `${t}.${rawBody}` using your endpoint secret. Verify against the raw request body — re-serializing parsed JSON changes the bytes.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i), kv.slice(i + 1)] as const;
    }),
  );
  if (!parts.t || !parts.v1) return false;

  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  if (!fresh) return false;

  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"),
    "hex",
  );
  const received = Buffer.from(parts.v1, "hex");
  // timingSafeEqual throws when lengths differ, so check first.
  return (
    received.length === expected.length && timingSafeEqual(received, expected)
  );
}
import hashlib
import hmac
import time


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() - int(t)) >= 300:
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)

Reject deliveries older than 5 minutes to prevent replay. Each retry is signed with a fresh timestamp, so retried deliveries still pass this check.

Retries and auto-disable

Respond with any 2xx within 10 seconds. Anything else (including timeouts) is retried with exponential backoff, for up to 5 attempts in total. Deliveries can arrive more than once or out of order — dedupe on the delivery id. After 15 consecutive terminally-failed deliveries the endpoint is disabled (enabled: false); fix your receiver and re-enable it with PATCH /v0/webhooks/{id}.

Testing

POST /v0/webhooks/{id}/test sends a signed ping event to your URL. Delivery history — status, attempts, last response code — is at GET /v0/webhooks/{id}/deliveries.

The test call returns 200 even when the endpoint is disabled (enabled: false), but nothing is sent: the delivery is recorded as FAILED with 0 attempts. If a test never arrives, check enabled and re-enable the endpoint with PATCH /v0/webhooks/{id} first.