Developer Docs

مستندات وب‌هوک برای توسعه‌دهندگان

اگر می‌خواهید سرویس خودتان نتیجه‌ی تولید محتوای Textoma را مستقیم و لحظه‌ای دریافت کند، این صفحه هر چیزی که برای ساخت یک endpoint امن و قابل‌اعتماد لازم دارید را توضیح می‌دهد: رویدادها، ساختار داده، نحوه‌ی تایید امضا، سیاست تلاش مجدد و نمونه کد آماده به چند زبان.

۱رویدادها (Events)

هر پروفایل وب‌هوک برای یک یا چند تا از این رویدادها فعال می‌شود:

رویدادزمان ارسال
job.startedهمان لحظه‌ای که پردازش سفارش (job) شروع می‌شود
job.completedوقتی مقاله با موفقیت تولید و آماده شد
job.failedوقتی تولید مقاله به هر دلیلی شکست بخورد

پیش‌فرض روی job.completed و job.failed فعال است؛ job.started اختیاری است.

۲ساختار داده (Payload)

i

بدنه‌ی دقیقی که ارسال می‌شود را خود کاربر Textoma می‌تواند با یک قالب سفارشی (template) تغییر دهد. آنچه زیر می‌بینید قالب پیش‌فرض و فیلدهای استانداردی است که همیشه در دسترس‌اند؛ اگر کاربر قالب خودش را تعریف کرده باشد، ممکن است اسم/ساختار فیلدها فرق کند — اما داده‌های زیرین (job, batch, article, meta) همیشه همین‌ها هستند.

payload — job.completed
{
  "event": "job.completed",
  "job": {
    "id": 9001,
    "title": "راهنمای شروع بازاریابی محتوایی",
    "status": "completed"
  },
  "batch": {
    "id": 501,
    "category": "بازاریابی و تبلیغات"
  },
  "article": {
    "title": "راهنمای شروع بازاریابی محتوایی",
    "summary": "خلاصه‌ی مقاله...",
    "content": "<h1>...</h1><p>محتوای کامل HTML مقاله...</p>",
    "tags": ["بازاریابی محتوایی", "تولید محتوا", "استراتژی دیجیتال"],
    "sections": [
      { "heading": "بازاریابی محتوایی چیست؟", "content": "..." },
      { "heading": "گام‌های اجرای یک استراتژی موفق", "content": "..." }
    ]
  },
  "sent_at": "2026-07-17T10:15:00+03:30"
}

اگر رویداد job.failed باشد، job.status برابر "failed" می‌شود و یک فیلد job.error هم با متن خطا اضافه می‌شود؛ در این حالت معمولاً article خالی می‌ماند.

فیلدهای در دسترس (صرف‌نظر از قالب)

فیلدتوضیح
job.idشناسه‌ی سفارش (job)
job.titleعنوان مقاله
job.statusprocessing / completed / failed
job.errorفقط در حالت شکست پر می‌شود
batch.idشناسه‌ی دسته (batch) که این job به آن تعلق دارد
batch.categoryعنوان دسته‌بندی محتوا
article.title / summary / contentمحتوای نهایی (content به‌صورت HTML خام)
article.tagsآرایه‌ای از رشته‌ها
article.toc / sectionsآرایه‌ای از بخش‌ها، هرکدام heading و content
article.faqآرایه‌ای از {question, answer}
meta.generated_atزمان تولید payload، فرمت RFC3339
meta.platformهمیشه "textoma"

۳هدرها

هدرمقدار
Content-Typeپیش‌فرض application/json (قابل تغییر در پنل)
X-Textoma-Signatureامضای HMAC-SHA256 بدنه‌ی خام درخواست، به‌صورت hex

هیچ هدر Authorization/Bearer دیگری ارسال نمی‌شود — تایید اصالت درخواست فقط از طریق همین هدر امضا انجام می‌شود.

۴تایید امضا (Signature Verification)

هنگام ساخت پروفایل وب‌هوک، یک Secret به شما داده می‌شود. Textoma برای هر درخواست، امضای زیر را حساب و در هدر X-Textoma-Signature قرار می‌دهد:

signature formula
hex( HMAC_SHA256( secret, raw_request_body ) )
۱. بدنه‌ی خام را نگه دارید قبل از پارس JSON، بایت خام درخواست را برای محاسبه‌ی امضا ذخیره کنید — حتی یک فاصله‌ی فرق‌کرده امضا را نامعتبر می‌کند.
۲. با ثابت‌زمان مقایسه کنید از تابع امن در برابر timing attack استفاده کنید: hmac.compare_digest، hash_equals، crypto.timingSafeEqual یا hmac.Equal.
۳. عدم تطابق = رد درخواست اگر امضا مطابقت نداشت، درخواست را با کد وضعیت ۴۰۱ رد کنید.
!

هیچ‌وقت امضا را با == ساده مقایسه نکنید — این کار سرور شما را در برابر timing attack آسیب‌پذیر می‌کند.

۵تلاش مجدد (Retry) و پاسخ مورد انتظار

  • سرور شما باید در بازه‌ی حدود ۱۵ ثانیه یک پاسخ با کد وضعیت ۲xx برگرداند. هر چیز دیگری (۴xx، ۵xx، تایم‌اوت، اتصال ناموفق) شکست محسوب می‌شود.
  • در صورت شکست، حداکثر ۶ تلاش کل (۱ تلاش اول + ۵ تلاش مجدد) با فاصله‌ی ۱، ۵، ۱۵، ۶۰ دقیقه انجام می‌شود (تلاش‌های بعدی هر ۶۰ دقیقه تکرار می‌شوند).
  • اگر همه‌ی تلاش‌ها شکست بخورد، دیگر تلاش مجددی انجام نمی‌شود.
  • پردازش سنگین (ذخیره در دیتابیس، انتشار در CMS و...) را async انجام دهید و سریع ۲۰۰ برگردانید.
  • چون retry ممکن است باعث دریافت یک رویداد مشابه بیش از یک‌بار شود، پردازش شما بهتر است idempotent باشد (بر اساس job.id + event چک کنید که قبلاً پردازش نشده باشد).

۶نمونه کد دریافت و تایید امضا

هر چهار نمونه دقیقاً یک کار را انجام می‌دهند: بدنه‌ی خام را می‌خوانند، امضا را با ثابت‌زمان تایید می‌کنند، سریع ۲۰۰ برمی‌گردانند و بعد رویداد را پردازش می‌کنند.

Node.js (Express)

webhook.js
const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.TEXTOMA_WEBHOOK_SECRET;

// باید بدنه‌ی خام (raw) رو نگه داریم، نه فقط JSON پارس‌شده
app.use(express.raw({ type: '*/*' }));

app.post('/webhooks/textoma', (req, res) => {
  const signature = req.header('X-Textoma-Signature') || '';
  const rawBody = req.body; // Buffer

  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  const isValid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!isValid) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const payload = JSON.parse(rawBody.toString('utf8'));

  // فوراً ۲۰۰ برگردونید، پردازش سنگین رو async انجام بدید
  res.status(200).json({ received: true });

  switch (payload.event) {
    case 'job.completed':
      console.log('article ready:', payload.article.title);
      break;
    case 'job.failed':
      console.error('job failed:', payload.job.error);
      break;
    case 'job.started':
      console.log('job started:', payload.job.id);
      break;
  }
});

app.listen(3000);

PHP

webhook.php
<?php

$secret = getenv('TEXTOMA_WEBHOOK_SECRET');

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_TEXTOMA_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);

http_response_code(200);
echo json_encode(['received' => true]);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

switch ($payload['event']) {
    case 'job.completed':
        error_log('article ready: ' . $payload['article']['title']);
        break;
    case 'job.failed':
        error_log('job failed: ' . ($payload['job']['error'] ?? ''));
        break;
    case 'job.started':
        error_log('job started: ' . $payload['job']['id']);
        break;
}

Python (Flask)

webhook.py
import hashlib
import hmac
import json
import os

from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["TEXTOMA_WEBHOOK_SECRET"]


@app.post("/webhooks/textoma")
def textoma_webhook():
    raw_body = request.get_data()  # بدنه‌ی خام، قبل از هر پارسی
    signature = request.headers.get("X-Textoma-Signature", "")

    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return jsonify({"error": "invalid signature"}), 401

    payload = json.loads(raw_body)

    event = payload.get("event")
    if event == "job.completed":
        print("article ready:", payload["article"]["title"])
    elif event == "job.failed":
        print("job failed:", payload["job"].get("error"))
    elif event == "job.started":
        print("job started:", payload["job"]["id"])

    return jsonify({"received": True}), 200


if __name__ == "__main__":
    app.run(port=3000)

Go

main.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
)

var webhookSecret = os.Getenv("TEXTOMA_WEBHOOK_SECRET")

type webhookPayload struct {
	Event string `json:"event"`
	Job   struct {
		ID     int    `json:"id"`
		Title  string `json:"title"`
		Status string `json:"status"`
		Error  string `json:"error"`
	} `json:"job"`
	Article struct {
		Title   string `json:"title"`
		Content string `json:"content"`
	} `json:"article"`
}

func sign(secret string, body []byte) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	return hex.EncodeToString(mac.Sum(nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	signature := r.Header.Get("X-Textoma-Signature")
	expected := sign(webhookSecret, rawBody)

	if !hmac.Equal([]byte(expected), []byte(signature)) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var payload webhookPayload
	if err := json.Unmarshal(rawBody, &payload); err != nil {
		http.Error(w, "invalid json", http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"received": true}`))

	switch payload.Event {
	case "job.completed":
		log.Println("article ready:", payload.Article.Title)
	case "job.failed":
		log.Println("job failed:", payload.Job.Error)
	case "job.started":
		log.Println("job started:", payload.Job.ID)
	}
}

func main() {
	http.HandleFunc("/webhooks/textoma", handler)
	log.Fatal(http.ListenAndServe(":3000", nil))
}

وردپرس (افزونه‌ی آماده)

اگر سایت شما وردپرسی است، نیازی به نوشتن کد نیست — افزونه‌ی رسمی Textoma Webhook Receiver همین دریافت و تایید امضا را خودش انجام می‌دهد و از هر job.completed یک نوشته می‌سازد.

فعال‌سازی سریع:

  1. در پیشخوان وردپرس: افزونه‌ها > افزودن > بارگذاری افزونه، فایل بالا را آپلود و فعال کنید.
  2. مسیر تنظیمات > Textoma Webhook را باز کنید و فیلدها (نوع پست، وضعیت انتشار، نویسنده، دسته‌بندی و غیره) را تنظیم کنید.
  3. آدرس وب‌هوکی که همان صفحه نشان می‌دهد (چیزی شبیه https://yourdomain.com/wp-json/textoma-webhook/v1/receive) را کپی کنید.
  4. در پنل Textoma، هنگام ساخت پروفایل وب‌هوک، همان آدرس را در فیلد URL بگذارید و یک Secret تعیین کنید؛ همان Secret را عیناً در فیلد Webhook Secret وردپرس هم وارد کنید.
  5. یک تست ارسال کنید و در نوشته‌های وردپرس بررسی کنید که پست ساخته شده باشد.
i

اگر Permalink سایت روی «ساده» (Plain) باشد، مسیرهای REST API ممکن است کار نکنند — از تنظیمات > پیوندهای یکتا یک ساختار غیر از Plain انتخاب کنید.

۷چک‌لیست پیاده‌سازی

  • endpoint شما بدنه‌ی خام (raw bytes) را قبل از پارس JSON، برای محاسبه‌ی امضا نگه می‌دارد.
  • امضا با تابع امن (constant-time) مقایسه می‌شود، نه == ساده.
  • در صورت نامعتبر بودن امضا، درخواست رد می‌شود (۴۰۱).
  • پاسخ ۲xx در کمتر از چند ثانیه برگردانده می‌شود؛ پردازش سنگین async انجام می‌شود.
  • پردازش رویدادها idempotent است (تکرار احتمالی به‌خاطر retry مشکلی ایجاد نمی‌کند).
  • هر سه رویداد (job.started, job.completed, job.failed) هندل می‌شوند، یا حداقل نادیده گرفتن رویدادهای ناشناس باعث خطا نمی‌شود.