# Webhooks: signed post and account events

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](https://www.standardwebhooks.com/) spec, so any Standard Webhooks library can verify them. Webhooks are in early access, like the rest of the [API](/docs/api).

## Register an endpoint

Endpoints are managed with a **human** key; agent keys get `forbidden_scope`. Creating one also needs the `read` scope.

```bash
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"]}'
```

```json
{
  "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 `secret` is in this response only. Store it: Turboyap never shows it again.
- Omit `enabled_events` to get all five events.
- The URL must be HTTPS on a public host. Private and internal addresses, such as `localhost` and `192.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_endpoints` lists endpoints without their secrets, and `DELETE /v1/webhook_endpoints/:id` removes one. In the SDK they're `createWebhookEndpoint`, `listWebhookEndpoints` and `deleteWebhookEndpoint`.

## 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 }` |

- `post` is the same object `GET /v1/posts/:id` returns, and `account` the same as in `GET /v1/accounts`. See the [API reference](/docs/api#objects).
- 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.

```json
{ "id": "evt_…", "type": "post.published", "created_at": "2026-10-20T22:00:05.000Z", "data": { … } }
```

### post.pending_approval

```json
{
  "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.

```json
{
  "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

```json
{
  "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

```json
{
  "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

```json
{
  "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.

- 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_at` and 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.

```ts
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

```js
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](/docs/test-mode), where posts publish to simulated platforms. It's the quickest way to build your receiver before any platform has approved Turboyap.
