Webhooks: signed post and account events
Get post.pending_approval, post.published, post.partial, post.failed and account.reauth_required events, signed with Standard Webhooks, with the retry schedule and verification code for the SDK and plain Node.
Turboyap can POST an event to your server when a post needs approval, when it finishes publishing, and when an account needs reconnecting. Deliveries follow the Standard Webhooks spec, so any Standard Webhooks library can verify them. Webhooks are in early access, like the rest of the API.
Register an endpoint
Endpoints are managed with a human key; agent keys get forbidden_scope. Creating one also needs the read scope.
curl https://api.turboyap.com/v1/webhook_endpoints \
-H "Authorization: Bearer $TURBOYAP_HUMAN_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/turboyap", "enabled_events": ["post.published", "post.partial", "post.failed"]}'
{
"webhook_endpoint": {
"id": "whk_…",
"url": "https://example.com/hooks/turboyap",
"enabled_events": ["post.published", "post.partial", "post.failed"],
"livemode": true,
"created_at": "2026-10-20T15:00:00.000Z",
"secret": "whsec_…"
}
}
- The
secretis in this response only. Store it: Turboyap never shows it again. - Omit
enabled_eventsto get all five events. - The URL must be HTTPS on a public host. Private and internal addresses, such as
localhostand192.168.x.x, are refused, so expose a local receiver through a tunnel while you develop. - A workspace can have 16 endpoints in each mode. An endpoint created with a test key only gets test-mode events, and a live endpoint only gets live ones.
GET /v1/webhook_endpointslists endpoints without their secrets, andDELETE /v1/webhook_endpoints/:idremoves one. In the SDK they’recreateWebhookEndpoint,listWebhookEndpointsanddeleteWebhookEndpoint.
Events
| Event | When it fires | data |
|---|---|---|
post.pending_approval |
An agent creates a post that waits for approval (level-1 accounts), or an agent edit sends an approved post back to review | { post } |
post.published |
Every target of the post is done and none failed: each one published, or delivered to the TikTok inbox | { post } |
post.partial |
Every target is done, and some failed while the rest published | { post } |
post.failed |
Every target is done, and all of them failed | { post } |
account.reauth_required |
A platform token can’t be refreshed, or the platform rejected it, so a human must reconnect the account | { account, reason } |
postis the same objectGET /v1/posts/:idreturns, andaccountthe same as inGET /v1/accounts. See the API reference.- The three outcome events fire once per post, after its last target settles.
- Rejected and cancelled targets are human decisions, not failures. A post whose remaining targets all published sends
post.published, and a post that was entirely rejected or cancelled sends no outcome event. - A TikTok target counts as done once it’s in the creator’s inbox, so no second event fires when the creator posts it.
Every event has the same envelope. Its id is also the webhook-id header and stays the same on every retry, so use it to ignore duplicates.
{ "id": "evt_…", "type": "post.published", "created_at": "2026-10-20T22:00:05.000Z", "data": { … } }
post.pending_approval
{
"id": "evt_…",
"type": "post.pending_approval",
"created_at": "2026-10-20T15:00:00.000Z",
"data": {
"post": {
"id": "post_…",
"type": "slideshow",
"caption": "Ice cream or frozen dairy dessert? Read the label.",
"media_ids": ["med_…", "med_…", "med_…"],
"source": "agent",
"created_by": { "type": "agent", "key_id": "key_…", "client": "claude-code/2.1" },
"livemode": true,
"idempotency_key": "icecream-2026-10-20",
"created_at": "2026-10-20T15:00:00.000Z",
"targets": [
{
"id": "tgt_…",
"account_id": "acct_…",
"platform": "tiktok",
"handle": "yourbrand",
"post_type": "slideshow",
"status": "pending_approval",
"scheduled_at": "2026-10-20T22:00:00.000Z",
"caption": "Ice cream or frozen dairy dessert? Read the label.",
"media_ids": ["med_…", "med_…", "med_…"],
"platform_post_id": null,
"platform_url": null,
"published_at": null,
"error": null,
"approved_by": null,
"next_step": "Waiting for a human to approve in Turboyap (account is on autonomy level 1)."
},
{ "id": "tgt_…", "platform": "instagram", "status": "pending_approval", … }
]
}
}
}
post.published
The same post after a human approved it: Instagram published, and TikTok delivered to the creator’s inbox.
{
"id": "evt_…",
"type": "post.published",
"created_at": "2026-10-20T22:00:05.000Z",
"data": {
"post": {
"id": "post_…",
"type": "slideshow",
…,
"targets": [
{
"id": "tgt_…",
"platform": "tiktok",
"status": "sent_to_inbox",
"approved_by": "key_…",
"next_step": "Delivered to the TikTok inbox. Open TikTok, add a sound, and post. Turboyap marks it published once it's live.",
…
},
{
"id": "tgt_…",
"platform": "instagram",
"status": "published",
"platform_post_id": "1789…",
"platform_url": "https://www.instagram.com/p/…",
"published_at": "2026-10-20T22:00:05.000Z",
"approved_by": "key_…",
"next_step": "Live at https://www.instagram.com/p/…",
…
}
]
}
}
}
post.partial
{
"id": "evt_…",
"type": "post.partial",
"created_at": "2026-10-20T22:00:10.000Z",
"data": {
"post": {
"id": "post_…",
…,
"targets": [
{
"id": "tgt_…",
"platform": "tiktok",
"status": "failed",
"error": {
"code": "picture_size_check_failed",
"message": "TikTok rejected the post: picture_size_check_failed.",
"fix": "Check the media and caption against get_platform_rules, then create the post again."
},
"next_step": "Failed; see error.fix.",
…
},
{ "id": "tgt_…", "platform": "instagram", "status": "published", "platform_url": "https://www.instagram.com/p/…", … }
]
}
}
}
post.failed
{
"id": "evt_…",
"type": "post.failed",
"created_at": "2026-10-20T22:00:10.000Z",
"data": {
"post": {
"id": "post_…",
…,
"targets": [
{
"id": "tgt_…",
"platform": "tiktok",
"status": "failed",
"error": { "code": "picture_size_check_failed", "message": "TikTok rejected the post: picture_size_check_failed.", "fix": "Check the media and caption against get_platform_rules, then create the post again." },
"next_step": "Failed; see error.fix.",
…
}
]
}
}
}
account.reauth_required
{
"id": "evt_…",
"type": "account.reauth_required",
"created_at": "2026-10-20T14:00:10.000Z",
"data": {
"account": {
"id": "acct_…",
"platform": "tiktok",
"handle": "yourbrand",
"livemode": true,
"display_name": "Your Brand",
"autonomy_level": 1,
"autonomy": "review everything: agent posts wait for human approval",
"status": "needs_reauth",
"status_reason": { "code": "invalid_grant", "message": "Refreshing the tiktok token failed: Refresh token revoked.", "at": "2026-10-20T14:00:10.000Z" },
"token_expires_at": "2026-10-20T14:10:10.000Z",
"next_step": "Reconnect @yourbrand on tiktok: a human opens a new connect link (…). Posts to this account fail until then.",
"connected_at": "2026-10-01T09:30:00.000Z"
},
"reason": { "code": "invalid_grant", "message": "Refreshing the tiktok token failed: Refresh token revoked." }
}
}
Headers and signature
| Header | Value |
|---|---|
webhook-id |
The event id (evt_…), the same on every retry |
webhook-timestamp |
Unix seconds when this attempt was sent |
webhook-signature |
v1, followed by the base64 HMAC-SHA256 signature |
Content-Type |
application/json |
User-Agent |
Turboyap-Webhooks/1 |
To check a signature:
- Take the part of the secret after
whsec_and base64-decode it. That’s the HMAC key. - Compute HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{body}, wherebodyis the raw request body exactly as received, not JSON you parsed and re-serialized. - Base64-encode the result and compare it, in constant time, with the part after
v1,. Turboyap sends one signature, but the Standard Webhooks format allows several separated by spaces, so accept the request if anyv1signature matches. - Reject timestamps more than five minutes from your clock, to stop replays.
Retries
Your endpoint must answer with a 2xx status within 10 seconds. Anything else counts as a failure, including a redirect, a timeout or a connection error.
- The first attempt goes out as soon as the worker picks up the event.
- After a failure, Turboyap tries again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: eight attempts over about 28 hours. After the eighth, the delivery is marked failed and isn’t retried.
- Delivery is at least once. If a worker stops mid-delivery, the event is sent again about a minute later, so deduplicate on
webhook-id. - Order isn’t guaranteed, since a retried event can arrive after newer ones. Use
created_atand the post’s current statuses rather than arrival order.
Answer quickly, and do slow work after you respond.
Verify with the SDK
verifyWebhook from @turboyap/sdk checks the headers, signature and timestamp, then returns the parsed event. The SDK is in early access and not on npm yet; the plain Node version below has no dependencies.
import { verifyWebhook, WebhookVerificationError } from "@turboyap/sdk";
// Any framework with Fetch API requests: Next.js route handlers, Hono, Bun, Deno, Workers.
export async function POST(request: Request): Promise<Response> {
const body = await request.text(); // the raw body, exactly as received
try {
const event = await verifyWebhook(process.env.TURBOYAP_WEBHOOK_SECRET!, request.headers, body);
if (event.type === "post.failed") {
for (const target of event.data.post?.targets ?? []) console.log(target.platform, target.error?.fix);
}
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
throw err;
}
}
verifyWebhook(secret, headers, body, { toleranceSeconds?, now? }) takes a Headers object or a plain header record (such as Node’s req.headers), and a string or Uint8Array body. It throws WebhookVerificationError when a header is missing, the signature doesn’t match, or the timestamp is outside the tolerance (5 minutes by default).
Verify in plain Node
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const key = Buffer.from(process.env.TURBOYAP_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
function verify(headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return null;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return null; // stale: possible replay
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"));
const valid = signatures.split(" ").some((entry) => {
const [version, signature = ""] = entry.split(",");
const given = Buffer.from(signature);
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
return valid ? JSON.parse(body) : null;
}
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const event = verify(req.headers, Buffer.concat(chunks).toString("utf8"));
if (!event) return res.writeHead(400).end();
// Deduplicate on event.id, then handle event.type.
res.writeHead(204).end();
});
}).listen(3000);
Test mode
Register an endpoint with a human test key (ty_human_test_…) and it gets only the events from test mode, where posts publish to simulated platforms. It’s the quickest way to build your receiver before any platform has approved Turboyap.
This page as markdown: /docs/webhooks.md. All docs for LLMs: /llms.txt and /llms-full.txt.