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:
| 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.
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:
| 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.
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:
| 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, 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.scheduledcan land afterpost.published. Treat events as facts about a moment, and re-read the post if you need current state. - Deliveries can arrive twice. A
2xxthat we never see because the connection dropped becomes a retry. Dedupe onid.
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
| 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 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.
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.