إشعارات Webhook
ما الذي يُسلّم
يرسل PDFgine استدعاءات راجعة لأمرين: تخبرك القائمة بانتهاء إنشاء مستند، ويخبرك العارض عندما يفتح الشخص الذي شاركت معه المستند إياه أو ينزّله.
document.render.succeeded— أُنشئ مستند من قائمة الانتظار وأصبح متاحًا للتنزيلdocument.render.failed— تعذر إنشاء مستند من قائمة الانتظار؛ يتضمن المحتوى failureCodedocument.viewed— فُتح مستند مشترك في العارضdocument.downloaded— نُزّل مستند مشترك
تُرسل أحداث الإنشاء إلى webhookUrl الذي تقدمه في طلب الإنشاء. وتُرسل أحداث العارض إلى الأطراف النهائية التي تسجلها مرة واحدة عبر POST /api/v1/engagement-webhooks؛ ويحدد كل طرف الأحداث التي يريدها.
ترويسات كل استدعاء
يحمل نوعا webhook الترويسات نفسها، لذا يكفي إجراء تحقق واحد للجميع.
| الترويسة | ما تحمله |
|---|---|
X-PDFgine-Event | نوع الحدث، مثل document.render.succeeded. |
X-PDFgine-Delivery | معرّف التسليم. يتكرر المعرّف نفسه عند إعادة الاستدعاء؛ استخدمه لجعل المعالج متساوي الأثر عند التكرار. |
X-PDFgine-Timestamp | وقت توقيع الاستدعاء، بثواني Unix وفق UTC. |
X-PDFgine-Signature | v1= يتبعه التوقيع بالصيغة الست عشرية بأحرف صغيرة. |
User-Agent | PDFgine-Webhooks/1.0 |
كيفية إنشاء التوقيع
يشمل التوقيع الطابع الزمني والمحتوى معًا، فلا يمكن إعادة تشغيل استدعاء ملتقط لاحقًا بطابع زمني جديد.
X-PDFgine-Signature: v1=<hex>
hex = HMAC-SHA256(
key = <the secret you registered>,
message = <X-PDFgine-Timestamp> + "." + <raw request body>
) // lower-case hexadecimal
وقّع البايتات الخام التي تلقيتها، ولا توقّع كائنًا أُعيد تسلسله مطلقًا. قد يعيد محلل JSON ترتيب المفاتيح أو يغير المسافات، وعندها لن يتطابق التوقيع؛ وهذا أكثر أسباب فشل التحقق شيوعًا.
سماحية الطابع الزمني
أنت من يحدد السماحية، لا PDFgine. ارفض الاستدعاء إذا ابتعد طابعه الزمني أكثر من خمس دقائق عن ساعتك؛ وهي مدة تستوعب الانحراف المعتاد وإعادات المحاولة، وتحد من صلاحية الاستدعاءات الملتقطة بسرعة.
قارن التوقيعين بزمن ثابت. تنتهي مقارنة النصوص العادية أسرع عند خطأ الحرف الأول منها عند خطأ الحرف الأخير، ويمكن قياس هذا الفرق.
تحقق جاهز للتشغيل
ينفذ المثالان الخطوات الأربع نفسها: قراءة المحتوى الخام، وفحص عمر الطابع الزمني، وإعادة حساب التوقيع، ومقارنته بزمن ثابت.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PDFGINE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// يلزم المحتوى الخام: تُبطل express.json() التوقيع لأنها تعيد كتابة JSON.
app.post("/hooks/pdfgine", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-PDFgine-Timestamp");
const header = req.get("X-PDFgine-Signature") ?? "";
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const supplied = header.startsWith("v1=") ? header.slice(3) : "";
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(supplied, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(req.get("X-PDFgine-Event"), req.get("X-PDFgine-Delivery"), event);
res.sendStatus(204);
});
app.MapPost("/hooks/pdfgine", async (HttpRequest request) =>
{
const int toleranceSeconds = 300;
var secret = Environment.GetEnvironmentVariable("PDFGINE_WEBHOOK_SECRET")!;
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
var timestampHeader = request.Headers["X-PDFgine-Timestamp"].ToString();
if (!long.TryParse(timestampHeader, out var timestamp)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > toleranceSeconds)
{
return Results.BadRequest();
}
var expected = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{timestamp}.{body}")))
.ToLowerInvariant();
var header = request.Headers["X-PDFgine-Signature"].ToString();
var supplied = header.StartsWith("v1=", StringComparison.Ordinal) ? header[3..] : "";
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(supplied)))
{
return Results.Unauthorized();
}
return Results.NoContent();
});
بنية المحتوى
تحمل أحداث الإنشاء غلافًا يتضمن معرّف التسليم ونوع الحدث وكائن data. وتحمل أحداث العارض الحقول مباشرة.
{
"id": "evt_8f1c4a7d6b2e4f0a91c3d5e7f9a1b3c5",
"type": "document.render.succeeded",
"createdAt": "2026-09-26T14:39:45+00:00",
"data": {
"queueId": "queue_fe86ad38402a4909b3b5a7965847924a",
"batchId": "batch_69c4ecb63f8642628b72bca89d4f280c",
"status": "succeeded",
"templateCode": "invoice",
"format": "pdf",
"renderJobId": "job_bfae00cc59354089bffd41fa298377b2",
"documentId": "doc_a96063f5dcd5400286ce9b3be9b0595b",
"failureCode": null
}
}
{
"previewId": "prv_510ff800bbcd4f848b0b769cf37ad4c2",
"templateCode": "invoice",
"deviceClass": "desktop",
"accessedAt": "2026-09-26T14:41:02+00:00",
"format": "pdf"
}
تختلف البنيتان حاليًا عن قصد، ويجب معرفة ذلك قبل كتابة المعالج: في أحداث العارض يوجد نوع الحدث في ترويسة X-PDFgine-Event فقط، وليس في المحتوى.
التسليم وإعادة المحاولة
يُدرج webhook في قائمة الانتظار ولا يُرسل ضمن التنفيذ نفسه، لذا لا يبطئ طرف نهائي بطيء لديك إنتاج المستندات.
- تُعد أي حالة 2xx تسليمًا ناجحًا. وكل ما عداها فشل، بما في ذلك إعادة التوجيه.
- حتى 6 محاولات، بفواصل تتزايد أُسّيًا بدءًا من 15 ثانية.
- يُعد الاستدعاء الذي لا يستجيب خلال 15 ثانية فاشلًا وتُعاد محاولته.
- يجب أن تستخدم الوجهة https وأن يُحل عنوانها إلى عنوان عام. تُرفض localhost وأسماء .local والنطاقات الخاصة عند تسجيل الطرف النهائي ومجددًا قبل كل استدعاء.
لأن إعادة المحاولة تكرر معرّف X-PDFgine-Delivery نفسه، سجّل المعرّفات التي عالجتها وأجب بالحالة 2xx عند التكرار دون تنفيذ العمل مرتين.
الأخطاء →الأخطاء الشائعة
- التحقق من محتوى أُعيد تسلسله لا يتطابق. تعيد أطر تحليل JSON ترتيب المفاتيح وتغير المسافات؛ احتفظ بالبايتات الخام قبل أن يمسها أي شيء.
- تُعد 2xx فقط تسليمًا ناجحًا. إعادة التوجيه 301 أو 302 إلى المعالج الفعلي فشل وتؤدي إلى إعادة المحاولة؛ استخدم العنوان النهائي.
- التحقق من التوقيع دون الطابع الزمني يبقي الاستدعاء الملتقط قابلًا لإعادة التشغيل إلى الأبد. ارفض ما يتجاوز سماحيتك.
- تكرر إعادة المحاولة معرّف X-PDFgine-Delivery نفسه. المعالج الذي يعيد العمل يرسل الفاتورة نفسها مرتين؛ سجّل المعرّفات التي عالجتها.
- يجب أن يستخدم الطرف المسجل https وأن يكون قابلًا للحل علنًا. تُرفض localhost والنطاقات الخاصة عند التسجيل؛ اختبر عبر نفق بدل محاولة تجاوز الرفض.