<!-- https://cutedyno.com/docs/webhooks -->

# Webhooks

Receive delivery results instead of polling, with signature verification and a documented retry schedule.

Publishing is asynchronous. A post moves through `queued`, `processing`, and then to `published`, `failed`, or `partially_published`, and how long that takes depends on the platform and the size of the video. Webhooks tell you when it lands, so you never have to poll.

## Subscribe

```typescript
const { subscription, secret } = await cutedyno.webhooks.create({
  url: 'https://yourapp.com/webhooks/cutedyno',
  events: ['post.published', 'post.failed', 'post.partially_published'],
});

// Store this now. It is the only time the secret is returned.
console.log(secret);
```

`scope` decides how much a subscription hears:

| Scope | Receives |
| --- | --- |
| `profile` (default) | Events from the one profile the subscription was created in. |
| `account` | Events from every profile in the account, each tagged with `profileId`. |

Multi-tenant platforms want `account`: one endpoint, one secret, routed by `profileId`. See [Build a platform](/docs/build-a-platform).

Subscribe to `["*"]` to receive everything, including events added later. That is convenient in development and a liability in production, where a new event type silently entering your handler is how outages start.

## The payload

Every delivery is a `POST` with this envelope:

```json
{
  "id": "3f9c1a80-0000-4000-8000-000000000000",
  "type": "post.partially_published",
  "createdAt": "2026-08-01T15:00:04.212Z",
  "data": {
    "profileId": "a1b2c3d4-0000-4000-8000-000000000000",
    "postId": "77f0e1a2-0000-4000-8000-000000000000",
    "status": "partially_published",
    "completedTargets": 1,
    "failedTargets": 1,
    "results": [
      {
        "targetId": "…",
        "platform": "instagram",
        "pageName": "acme",
        "status": "success",
        "result": { "id": "17912345678901234" }
      },
      {
        "targetId": "…",
        "platform": "tiktok",
        "pageName": "acmehq",
        "status": "failed",
        "error": "Video must be at least 3 seconds long."
      }
    ]
  }
}
```

`id` is the delivery ID. It is stable across retries, which makes it the right key to dedupe on. `data` always carries `profileId`; the rest depends on `type`.

`data.status` mirrors the post's status. A `state` field carrying the same value is still sent for older receivers, but it will be removed — read `status`.

Four headers come with it:

| Header | Purpose |
| --- | --- |
| `CuteDyno-Signature` | `t=<unix>,v1=<hex>`. Verify this before parsing anything. |
| `CuteDyno-Event` | The event type, so you can route without parsing the body. |
| `CuteDyno-Event-Id` | Same as `id`. Use it to dedupe. |
| `CuteDyno-Delivery-Attempt` | `1` on the first try, incrementing on retries. |

## Verify the signature

Anyone can POST to your endpoint. The signature is what makes the payload trustworthy, and every field you act on — `profileId` most of all — depends on it.

```typescript
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from '@cutedyno/node';

app.post(
  '/webhooks/cutedyno',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event;
    try {
      event = verifyWebhook({
        payload: req.body,
        signature: req.header('CuteDyno-Signature'),
        secret: process.env.CUTEDYNO_WEBHOOK_SECRET!,
      });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return res.sendStatus(400);
      throw error;
    }

    // Acknowledge first. Ten seconds is the whole budget.
    res.sendStatus(200);
    await queue.add('cutedyno-event', event);
  }
);
```

Note `express.raw`. The signature covers the exact bytes CuteDyno sent, and `express.json()` reparses and reserialises them, which changes the bytes and breaks verification. This is the single most common webhook problem.

Without the SDK it is a five-line HMAC:

```python
import hashlib
import hmac
import time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    timestamp, received = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, received)
```

Use a constant-time comparison, as above. A plain `==` leaks the signature one byte at a time to anyone patient enough to measure.

The timestamp check is what stops replays: without it, a captured payload stays valid forever. Five minutes of tolerance covers ordinary clock skew.

## Retries

A delivery counts as successful on any `2xx`. Anything else — including a timeout after 10 seconds — is retried on this schedule:

| Attempt | Delay after previous |
| --- | --- |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |

Seven attempts over roughly 31 hours, then the delivery is abandoned. Inspect what happened with [`GET /v1/webhooks/:id/deliveries`](/docs/api/list-webhook-deliveries), which records status, attempt count, response code, and the first 2000 bytes of your response body.

Two consequences worth designing for:

- **Deliveries can arrive out of order.** A retried `post.scheduled` can land after `post.published`. Treat events as facts about a moment, and re-read the post if you need current state.
- **Deliveries can arrive twice.** A `2xx` that we never see because the connection dropped becomes a retry. Dedupe on `id`.

## Acknowledge fast, process later

Return `200` as soon as the signature checks out, then do the work on a queue. If you publish to five platforms and your handler makes five database writes and an email send before responding, you will eventually exceed 10 seconds, and CuteDyno will retry an event you already handled.

## Test before you ship

[`POST /v1/webhooks/:id/test`](/docs/api/test-webhook) delivers a synthetic event with the same shape, signature, and headers as the real thing:

```typescript
await cutedyno.webhooks.test(subscription.id, { eventType: 'post.published' });
```

The payload carries `"test": true` so a receiver can distinguish it. Check the result in the delivery log, or in the dashboard under [Webhooks](/dashboard/webhooks).

## Changing an endpoint

[`PATCH /v1/webhooks/:id`](/docs/api/update-webhook) changes the URL or the event list, and pauses delivery:

```typescript
await cutedyno.webhooks.update(subscription.id, { isActive: false });
```

A paused subscription keeps its secret and its history, and events that occur while it is paused are not queued for later — they are simply not delivered. Pause during a deploy you expect to break; delete when you are done with the endpoint for good.

## Rotating the secret

[`POST /v1/webhooks/:id/rotate-secret`](/docs/api/rotate-webhook-secret) issues a new signing secret and returns it once:

```typescript
const { secret } = await cutedyno.webhooks.rotateSecret(subscription.id);
```

The old secret stops verifying immediately, so there is no overlap window. Deploy the new value to your receiver before rotating, or pause the subscription, rotate, deploy, and resume.

## Event reference

Grouped by resource. Each fires once per state change.

### Accounts

| Event | When |
| --- | --- |
| `account.connected` | A social account finished connecting to a profile. |
| `account.disconnected` | An account was removed, or the platform revoked its token. |
| `connection.completed` | A connect session succeeded. `data.accounts` lists what was added. |
| `connection.failed` | A connect session failed or was abandoned. |

`account.disconnected` deserves a real handler. Platform tokens get revoked when a user changes their password or removes your app, and until they reconnect, every post to that account fails.

### Posts

| Event | When |
| --- | --- |
| `post.created` | A post was created, in any starting state. |
| `post.scheduled` | A post was scheduled for a future time. |
| `post.published` | Published successfully to every target account. |
| `post.partially_published` | Published to some accounts and failed on others. |
| `post.failed` | Failed on every target account. |
| `post.cancelled` | Cancelled before it published. |

`post.partially_published` is not an edge case. It is what happens when Instagram accepts a video and TikTok rejects it for duration, in the same post. `data.results` breaks it down per account, and [`POST /v1/posts/:id/retry`](/docs/api/retry-post) requeues only what failed.

### Approvals

| Event | When |
| --- | --- |
| `approval.requested` | A post entered approval and approvers were notified. |
| `approval.approved` | An approver approved it. Publishing continues. |
| `approval.rejected` | An approver rejected it. It stays unpublished. |

These matter when an agent is creating the posts. See [agent governance](/docs/guides/agent-governance).

### Comments

| Event | When |
| --- | --- |
| `comment.received` | A new comment arrived on published content. |

Facebook and Instagram only, and only for accounts where comment sync is enabled.
