sangtao.ai
API key

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

WayGetsSet up
AAn 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 APIPOST /webhooks. You can test it, see each delivery and retry it
BPer request: the events of that one image job, video job or post onlywebhookUrl (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

typeWhendata
image.completedAn image job finishedAs GET /images/generations/{jobId}, plus source
image.failedAn image job failedAs GET /images/generations/{jobId}, plus source
video.completedA video job made through the API finished: POST /render/video or POST /creative-videos/createAs GET /jobs/{jobId}, plus source
video.failedThat video job failed (its credits refunded)As GET /jobs/{jobId}, plus source
post.publishedA post went out on every networkAs GET /publishing/posts/{id}
post.failedA post failed, or went out on only some networks (data.status is PartiallyPublished)As GET /publishing/posts/{id}
social_account.needs_attentionA posting page changed or was lost, or the account was disconnected: posting to it stops until a page is picked againAn item of GET /publishing/teams/{teamId}/accounts, plus teamId and attentionReason
webhook.testSent 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 / fieldMeaning
X-Sangtao-EventThe event type
X-Sangtao-DeliveryThis delivery, dlv_… — a new one on each retry
X-Sangtao-TimestampUnix seconds, signed with the body
X-Sangtao-Signaturev1=<hex>; two of them, comma-separated, for 24 hours after rotating the secret. Absent for way B without webhookSecret
idThe event, evt_… — the same on every retry: deduplicate on it
truncatedtrue 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}" ) )
  1. Read the body as raw bytes — parsing and re-serialising the JSON changes it.
  2. Refuse a timestamp more than 5 minutes off your clock: that stops replays.
  3. 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?

  1. GET /webhooks/{id}: is status active, and is the event in events?
  2. Did it happen after the endpoint was created?
  3. 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.
  4. 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.