sangtao.ai
API key

Webhook

Webhook

Nhận request HTTPS POST khi ảnh hoặc video xong, bài đăng xong hoặc trang đăng bài cần chọn lại.

Thay vì poll, hãy đưa một URL: khi ảnh hoặc video xong, bài đăng xong hay trang đăng bài cần chọn lại, hệ thống gửi một request HTTPS POST tới đó. Sự kiện thường tới trong 10 giây. Quản lý endpoint cần key có scope webhooks:write.

Hai cách nhận

CáchNhận gìCài đặt
AEndpoint theo tài khoản: mọi sự kiện đã đăng ký, từ mọi ảnh và bài đăng của bạn — tạo qua API, trên web hay theo lô — và các video bạn tạo qua APIPOST /webhooks. Gửi thử được, xem được từng lần giao và gửi lại
BTheo request: chỉ sự kiện của đúng job ảnh, job video hoặc bài đăng đówebhookUrl (và webhookSecret) trong POST /images/generations, POST /render/video, POST /creative-videos/create hoặc POST /publishing/posts

Áp dụng cả hai thì sự kiện tới cả hai. Endpoint chỉ nhận sự kiện xảy ra sau lúc tạo — không gửi bù việc cũ.

Sự kiện

typeKhi nàodata
image.completedJob ảnh xongNhư GET /images/generations/{jobId}, thêm source
image.failedJob ảnh hỏngNhư GET /images/generations/{jobId}, thêm source
video.completedJob video tạo qua API xong: POST /render/video hoặc POST /creative-videos/createNhư GET /jobs/{jobId}, thêm source
video.failedJob video đó hỏng (đã hoàn credit)Như GET /jobs/{jobId}, thêm source
post.publishedBài đã lên mọi nền tảngNhư GET /publishing/posts/{id}
post.failedBài hỏng, hoặc chỉ lên một số nền tảng (data.status là PartiallyPublished)Như GET /publishing/posts/{id}
social_account.needs_attentionTrang đăng bài bị đổi, mất quyền, hoặc tài khoản bị ngắt: ngừng đăng lên đó tới khi chọn lại trangMột mục của GET /publishing/teams/{teamId}/accounts, thêm teamId và attentionReason
webhook.testChỉ gửi qua POST /webhooks/{id}/test — không đăng ký được{ "message": "test" }

source cho biết ảnh tạo ở đâu: api, web hoặc batch. Ảnh sinh bên trong job khác (khung hình của video chẳng hạn) không gửi sự kiện. attentionReason là CHANNEL_CHANGED, CHANNEL_LOST hoặc 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"
  }
}

Một lần giao gồm gì

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 / trườngÝ nghĩa
X-Sangtao-EventLoại sự kiện
X-Sangtao-DeliveryLần giao này, dlv_… — mỗi lần gửi lại là mã mới
X-Sangtao-TimestampGiây Unix, được ký cùng body
X-Sangtao-Signaturev1=<hex>; có hai chữ ký cách nhau dấu phẩy trong 24 giờ sau khi xoay secret. Không có với cách B khi không gửi webhookSecret
idSự kiện, evt_… — giữ nguyên qua mọi lần gửi lại: khử trùng theo mã này
truncatedtrue khi data quá 256 KB: data chỉ còn { id } — gọi GET để lấy đủ

Kiểm chữ ký

text
v1 = hex( HMAC-SHA256( secret, "{X-Sangtao-Timestamp}.{raw body}" ) )
  1. Đọc body dạng byte gốc — parse rồi dựng lại JSON sẽ làm đổi nội dung.
  2. Từ chối timestamp lệch quá 5 phút so với giờ máy bạn: chống gửi lại request cũ.
  3. Tính HMAC bằng cả chuỗi secret, gồm whsec_, rồi so bằng hàm so hằng thời gian với từng giá trị v1=; khớp một cái là được.
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)

Giao và thử lại

  • Trả 2xx trong 10 giây. Xử lý sau khi đã trả — đưa vào hàng đợi.
  • Lỗi mạng, quá thời gian, 5xx, 429 hoặc 408 thì thử lại: ngay, rồi sau 10 giây, 1 phút, 5 phút, 30 phút, 2 giờ, 6 giờ và 24 giờ. 4xx khác thì dừng luôn.
  • Không theo redirect.
  • Một sự kiện có thể tới nhiều lần: khử trùng theo id.
  • Sự kiện có thể tới lệch thứ tự: dựa vào data.status và createdAt, đừng dựa vào thứ tự nhận.
  • Endpoint hỏng liên tục 3 ngày sẽ tự tắt (status disabled_failing) và chủ tài khoản nhận email. PATCH với enabled: true để bật lại.
  • Lịch sử giao giữ 30 ngày.

Không nhận được?

  1. GET /webhooks/{id}: status có là active không, events có loại sự kiện đó không?
  2. Sự kiện có xảy ra sau lúc tạo endpoint không?
  3. GET /webhooks/{id}/deliveries, rồi GET /webhooks/deliveries/{deliveryId}: mã HTTP từng lần thử, 1 KB đầu URL của bạn trả về, và body — đúng chuỗi đã ký, dùng để thử lại mã kiểm chữ ký của bạn.
  4. POST /webhooks/{id}/test gửi ngay một sự kiện và trả kết quả.
Sửa xong URL? Gửi lại lần giao hỏng bằng POST /webhooks/{id}/deliveries/{deliveryId}/retry.