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
| Event | Fires when |
|---|---|
post.published | A post finished publishing to its platform |
post.failed | A publish attempt terminally failed |
generation.completed | An AI image, video, or portrait generation finished rendering |
generation.failed | An AI generation failed |
social_account.connected | A 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
| Field | Description |
|---|---|
postId | The post's id — fetch it with GET /v0/posts/{id} |
status | COMPLETED for post.published, FAILED for post.failed |
socialAccountId | The channel the post was published to |
url | Public URL of the published post, when the platform provides one (else null) |
externalId | The platform's id for the published post (else null) |
errorMessage | Why 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 }
| Field | Description |
|---|---|
organizationId | The workspace the account was connected to |
socialAccount.id | The account's id — fetch it with GET /v0/social-accounts/{id} |
socialAccount.platform | The platform slug, e.g. instagram, tiktok, facebook |
socialAccount.username | The account's handle or display name on the platform (else null) |
socialAccount.profileId | The client profile the account belongs to — set when a client connected through a connect link (POST /v0/profiles/{id}/connect-link), else null |
socialAccount.status | Always connected |
reconnected | true 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:
| Header | Meaning |
|---|---|
x-schedulin-event | The event name |
x-schedulin-delivery-id | Unique delivery id, same as the body's id (use for idempotency) |
x-schedulin-signature | t=<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.