turboyap

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_…"
  }
}

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 }

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:

  1. Take the part of the secret after whsec_ and base64-decode it. That’s the HMAC key.
  2. Compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, where body is the raw request body exactly as received, not JSON you parsed and re-serialized.
  3. 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 any v1 signature matches.
  4. 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.

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.