Docs

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

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:

ScopeReceives
profile (default)Events from the one profile the subscription was created in.
accountEvents 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.

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:

{
  "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:

HeaderPurpose
CuteDyno-Signaturet=<unix>,v1=<hex>. Verify this before parsing anything.
CuteDyno-EventThe event type, so you can route without parsing the body.
CuteDyno-Event-IdSame as id. Use it to dedupe.
CuteDyno-Delivery-Attempt1 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.

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:

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:

AttemptDelay after previous
230 seconds
32 minutes
410 minutes
51 hour
66 hours
724 hours

Seven attempts over roughly 31 hours, then the delivery is abandoned. Inspect what happened with GET /v1/webhooks/:id/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 delivers a synthetic event with the same shape, signature, and headers as the real thing:

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.

Changing an endpoint

PATCH /v1/webhooks/:id changes the URL or the event list, and pauses delivery:

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 issues a new signing secret and returns it once:

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

EventWhen
account.connectedA social account finished connecting to a profile.
account.disconnectedAn account was removed, or the platform revoked its token.
connection.completedA connect session succeeded. data.accounts lists what was added.
connection.failedA 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

EventWhen
post.createdA post was created, in any starting state.
post.scheduledA post was scheduled for a future time.
post.publishedPublished successfully to every target account.
post.partially_publishedPublished to some accounts and failed on others.
post.failedFailed on every target account.
post.cancelledCancelled 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 requeues only what failed.

Approvals

EventWhen
approval.requestedA post entered approval and approvers were notified.
approval.approvedAn approver approved it. Publishing continues.
approval.rejectedAn approver rejected it. It stays unpublished.

These matter when an agent is creating the posts. See agent governance.

Comments

EventWhen
comment.receivedA new comment arrived on published content.

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