Webhooks
Webhook
Get an HTTPS POST when an image or a video finishes, a post goes out or a posting page needs picking again.
Instead of polling, give us a URL and we send an HTTPS POST to it when an image or a video finishes, a post goes out or a page needs picking again. An event usually arrives within 10 seconds. Managing endpoints needs a key with the webhooks:write scope.
Two ways to receive
| Way | Gets | Set up |
|---|---|---|
| A | An endpoint on your account: every event you subscribed to, from all your images and posts — made through the API, on the website or in a batch — and the videos you make through the API | POST /webhooks. You can test it, see each delivery and retry it |
| B | Per request: the events of that one image job, video job or post only | webhookUrl (and webhookSecret) in POST /images/generations, POST /render/video, POST /creative-videos/create or POST /publishing/posts |
An event goes to both when both apply. An endpoint only gets events that happen after it was created — nothing from before.
Events
| type | When | data |
|---|---|---|
| image.completed | An image job finished | As GET /images/generations/{jobId}, plus source |
| image.failed | An image job failed | As GET /images/generations/{jobId}, plus source |
| video.completed | A video job made through the API finished: POST /render/video or POST /creative-videos/create | As GET /jobs/{jobId}, plus source |
| video.failed | That video job failed (its credits refunded) | As GET /jobs/{jobId}, plus source |
| post.published | A post went out on every network | As GET /publishing/posts/{id} |
| post.failed | A post failed, or went out on only some networks (data.status is PartiallyPublished) | As GET /publishing/posts/{id} |
| social_account.needs_attention | A posting page changed or was lost, or the account was disconnected: posting to it stops until a page is picked again | An item of GET /publishing/teams/{teamId}/accounts, plus teamId and attentionReason |
| webhook.test | Sent by POST /webhooks/{id}/test only — not subscribed to | { "message": "test" } |
source says where the image was made: api, web or batch. Images made inside other jobs (a video’s frames, for one) send no event. attentionReason is CHANNEL_CHANGED, CHANNEL_LOST or DISCONNECTED.
image.completed
json
{
"id": "evt_8Kq2mXa7T",
"type": "image.completed",
"createdAt": "2026-11-10T09:43:31Z",
"apiVersion": "v2",
"data": {
"jobId": "acff47cbfacf4e27bbc08770c9ea246d",
"type": "AgentJob",
"category": "agent",
"typeDisplayName": "Agent Job",
"title": null,
"prompt": null,
"status": "complete",
"progress": 100,
"resultUrl": "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png",
"resultImages": [
"https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png"
],
"resultData": {
"resultUrl": "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png",
"imageUrls": [
"https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png"
],
"width": 1024,
"height": 1024,
"durationMs": 13100
},
"error": null,
"failureKind": null,
"hasDownloadableAssets": true,
"queuePosition": null,
"etaSeconds": null,
"etaCompleteSeconds": null,
"elapsedSeconds": 13.1,
"creditCost": 0,
"createdAt": "2026-09-19T09:42:59Z",
"processingAt": "2026-09-19T09:43:17Z",
"completedAt": "2026-09-19T09:43:30Z",
"model": "gpt-image-latest",
"resolvedModel": "gpt-image-2.6",
"server": "standard",
"source": "api"
}
}post.failed
json
{
"id": "evt_3Rb9nLc2W",
"type": "post.failed",
"createdAt": "2026-11-10T02:04:12Z",
"apiVersion": "v2",
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"teamId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "PartiallyPublished",
"createdAt": "2026-11-10T02:00:00Z",
"platforms": [
{
"id": "pp_3kT9qPz0B",
"platform": "TikTok",
"status": "Published",
"externalUrl": "https://www.tiktok.com/@yourchannel/video/1234567890",
"error": null
},
{
"id": "pp_9pW4nKt1H",
"platform": "YouTube",
"status": "Failed",
"externalUrl": null,
"error": "The video is too long for Shorts"
}
]
}
}social_account.needs_attention
json
{
"id": "evt_6Hn4pQd8Z",
"type": "social_account.needs_attention",
"createdAt": "2026-11-10T05:12:00Z",
"apiVersion": "v2",
"data": {
"id": "sa_7mR2xLq5D",
"platform": "Facebook",
"displayName": "Your Page",
"channelId": "1234567890",
"channelName": "Your Page",
"isConnected": true,
"teamId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attentionReason": "CHANNEL_CHANGED"
}
}What a delivery carries
http
POST /webhooks/sangtao HTTP/1.1
Content-Type: application/json
User-Agent: sangtao-webhooks/1
X-Sangtao-Event: image.completed
X-Sangtao-Delivery: dlv_5Vt1kRm9P
X-Sangtao-Timestamp: 1762767811
X-Sangtao-Signature: v1=5f2b1c…| Header / field | Meaning |
|---|---|
| X-Sangtao-Event | The event type |
| X-Sangtao-Delivery | This delivery, dlv_… — a new one on each retry |
| X-Sangtao-Timestamp | Unix seconds, signed with the body |
| X-Sangtao-Signature | v1=<hex>; two of them, comma-separated, for 24 hours after rotating the secret. Absent for way B without webhookSecret |
| id | The event, evt_… — the same on every retry: deduplicate on it |
| truncated | true when data was over 256 KB: data is then just { id } — fetch the rest with GET |
Checking the signature
text
v1 = hex( HMAC-SHA256( secret, "{X-Sangtao-Timestamp}.{raw body}" ) )- Read the body as raw bytes — parsing and re-serialising the JSON changes it.
- Refuse a timestamp more than 5 minutes off your clock: that stops replays.
- Compute the HMAC with the whole secret, whsec_ included, and compare in constant time against each v1= value; one match is enough.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.SANGTAO_WEBHOOK_SECRET // whsec_…
// express.raw: the signature is over the bytes as sent, not re-serialised JSON
app.post('/webhooks/sangtao', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Sangtao-Timestamp') ?? ''
const header = req.get('X-Sangtao-Signature') ?? ''
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400)
const expected = crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest('hex')
const ok = header.split(',').some((part) => {
const sig = part.trim().replace(/^v1=/, '')
return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
})
if (!ok) return res.sendStatus(400)
const event = JSON.parse(req.body)
res.sendStatus(200) // answer first, within 10 seconds
// then: skip if event.id was handled already, else queue the work
})
app.listen(3000)Delivery and retries
- Answer 2xx within 10 seconds. Do the work after answering — queue it.
- A network error, a timeout, 5xx, 429 or 408 is retried: at once, then after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Any other 4xx stops at once.
- Redirects are not followed.
- An event can arrive more than once: deduplicate on id.
- Events can arrive out of order: rely on data.status and createdAt, not on arrival.
- An endpoint that fails for 3 days in a row is turned off (status disabled_failing) and the account owner gets an email. PATCH it with enabled: true to turn it back on.
- Deliveries are kept 30 days.
Nothing arrives?
- GET /webhooks/{id}: is status active, and is the event in events?
- Did it happen after the endpoint was created?
- GET /webhooks/{id}/deliveries, then GET /webhooks/deliveries/{deliveryId}: each attempt’s HTTP status, the first 1 KB your URL answered, and body — the exact bytes signed, to check your signature code against.
- POST /webhooks/{id}/test sends one now and returns the result.
Fixed your URL? Send a failed delivery again with POST /webhooks/{id}/deliveries/{deliveryId}/retry.