Panduan
Webhook
TheComNet mengirim (POST) event JSON bertanda tangan ke endpoint HTTPS Anda saat pesanan, retur, stok, atau chat berubah.
Penyiapan
- Di TheComNet buka Pengaturan → Integrasi lalu tambahkan URL endpoint webhook (maksimal 10 per tenant).
- Pilih event yang ingin diikuti.
- Salin signing secret (
whsec_…). Secret bisa ditampilkan lagi atau dirotasi nanti; setelah rotasi, hanya secret baru yang menandatangani. - Tekan Kirim tes untuk mengirim event
pinglalu periksa log pengiriman.
Event
| Event | Dikirim saat |
|---|---|
order.created | Pesanan baru masuk dari toko yang terhubung. previousStatus bernilai null. |
order.status_changed | Pesanan berpindah ke status ternormalisasi lain (paid, shipped, delivered, cancelled, …). |
aftersale.created | Pembeli membuka permintaan pembatalan atau retur. type dan status adalah nilai milik marketplace. |
aftersale.completed | Retur dengan barang yang dikirim balik telah selesai. |
inventory.stock_changed | Stok yang bisa dijual (stok fisik dikurangi safety stock, tidak pernah di bawah 0) dari item master berubah. |
chat.message_received | Pembeli mengirim pesan chat. text berisi deskripsi singkat untuk pesan non-teks. |
ping | Anda menekan Kirim tes di konsol. data berisi { "message": "Webhook test from TheComNet" }. Tidak bisa diikuti. |
Envelope
Setiap pengiriman memiliki bentuk tingkat atas yang sama:
| Field | Tipe | Arti |
|---|---|---|
id | string (uuid) | Id event. Event yang sama yang dikirim lagi (retry atau Kirim ulang) tetap memakai id ini; lakukan dedupe dengannya. |
type | string | Tipe event, juga dikirim sebagai x-thecomnet-event. |
createdAt | string (date-time) | Waktu event disebarkan ke endpoint Anda (UTC). |
tenantId | string (uuid) | Tenant pemilik event. |
data | object | Payload khusus event, lihat di bawah. |
simulated | boolean | Hanya ada pada event simulasi, selalu true. |
order.created
POST body
{
"id": "0192f0d0-aaaa-7bbb-8ccc-ddddeeeeffff",
"type": "order.created",
"createdAt": "2026-11-04T07:12:31.204Z",
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"data": {
"orderId": "0192f0c6-3b4c-7d5e-9f60-718293a4b5c6",
"storeId": "0192f0c2-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"marketplace": "tiktok",
"externalOrderId": "578123456789012345",
"status": "pending_payment",
"previousStatus": null,
"placedAt": "2026-11-04T07:12:09.000Z"
}
}order.status_changed
POST body
{
"id": "0192f0d0-aaaa-7bbb-8ccc-ddddeeeeffff",
"type": "order.status_changed",
"createdAt": "2026-11-04T07:12:31.204Z",
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"data": {
"orderId": "0192f0c6-3b4c-7d5e-9f60-718293a4b5c6",
"storeId": "0192f0c2-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"marketplace": "tiktok",
"externalOrderId": "578123456789012345",
"status": "shipped",
"previousStatus": "processing",
"placedAt": "2026-11-04T07:12:09.000Z"
}
}aftersale.created
POST body
{
"id": "0192f0d0-aaaa-7bbb-8ccc-ddddeeeeffff",
"type": "aftersale.created",
"createdAt": "2026-11-04T07:12:31.204Z",
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"data": {
"orderId": "0192f0c6-3b4c-7d5e-9f60-718293a4b5c6",
"storeId": "0192f0c2-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"externalOrderId": "578123456789012345",
"externalReturnId": "4035987654321098765",
"type": "RETURN_AND_REFUND",
"status": "RETURN_OR_REFUND_REQUEST_PENDING",
"reason": "Item does not match description"
}
}aftersale.completed
POST body
{
"id": "0192f0d0-aaaa-7bbb-8ccc-ddddeeeeffff",
"type": "aftersale.completed",
"createdAt": "2026-11-04T07:12:31.204Z",
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"data": {
"orderId": "0192f0c6-3b4c-7d5e-9f60-718293a4b5c6",
"storeId": "0192f0c2-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"externalReturnId": "4035987654321098765"
}
}inventory.stock_changed
POST body
{
"id": "0192f0d0-aaaa-7bbb-8ccc-ddddeeeeffff",
"type": "inventory.stock_changed",
"createdAt": "2026-11-04T07:12:31.204Z",
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"data": {
"itemId": "0192f0c8-5d6e-7f80-91a2-b3c4d5e6f708",
"sku": "KAOS-HITAM-L",
"name": "Kaos Polos Hitam L",
"previousAvailable": 12,
"available": 11,
"reason": "order"
}
}chat.message_received
POST body
{
"id": "0192f0d0-aaaa-7bbb-8ccc-ddddeeeeffff",
"type": "chat.message_received",
"createdAt": "2026-11-04T07:12:31.204Z",
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"data": {
"conversationId": "0192f0c9-6e7f-7091-a2b3-c4d5e6f70819",
"storeId": "0192f0c2-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"externalMessageId": "7301234567890123456",
"text": "Kak, pesanan saya sudah dikirim belum?"
}
}Catatan payload
statusdanpreviousStatuspesanan adalah status ternormalisasi:pending_payment,processing,shipped,delivered,completed,cancelled,returned. Status khusus marketplace ada di pesanan (marketplaceStatus); ambil denganGET /v1/orders/{orderId}.reasonpadainventory.stock_changedadalah alasan mutasi stok:adjust,set,order,cancel,receive,issue,transfer_in,transfer_out,stocktake,return.- Event hanya membawa id dan field yang berubah. Ambil resource lengkap dari API bila butuh lebih.
Event simulasi
Tekan Simulasikan event di Pengaturan → Integrasi → Webhook (atau panggil POST /v1/developer/sandbox/events dengan session token) untuk mengantrekan event contoh dari tipe apa pun ke setiap endpoint yang berlangganan. Event ini membawa simulated: true dan tanda tangan seperti biasa. Abaikan event seperti ini di produksi. Lihat Sandbox: event simulasi.
Header request
| Header | Nilai |
|---|---|
content-type | application/json |
user-agent | TheComNet-Webhooks/1.0 |
x-thecomnet-event | Tipe event, misalnya order.created. |
x-thecomnet-delivery | Id pengiriman (satu per endpoint dan event). Tetap sama di setiap retry dan Kirim ulang. |
x-thecomnet-signature | t=<unix seconds>,v1=<hex> |
Memverifikasi tanda tangan
- Parse
x-thecomnet-signaturemenjadit(detik Unix) danv1. - Hitung
v1 = hex(HMAC-SHA256(secret, `${t}.${rawBody}`))atas raw request body persis seperti yang diterima. Secret-nya adalah seluruh stringwhsec_…. - Bandingkan dengan
v1yang diterima secara constant-time. - Tolak request bila
tberselisih lebih dari 5 menit dari jam Anda (perlindungan replay). Setiap percobaan ditandatangani dengantyang baru.
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_SECONDS = 5 * 60;
/** True when header `t=<unix>,v1=<hex>` signs rawBody with secret and is fresh. */
export function verifySignature(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(
header.split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const { t, v1 } = parts;
if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false;
if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}
const app = express();
const seen = new Set(); // use a database table in production
// Raw body: the signature covers the exact bytes sent, not re-serialized JSON.
app.post('/webhooks/thecomnet', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
const ok = verifySignature(process.env.THECOMNET_WEBHOOK_SECRET, req.get('x-thecomnet-signature') ?? '', raw);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(raw);
res.sendStatus(204); // acknowledge within 10 s, process afterwards
if (seen.has(event.id)) return; // at-least-once: dedupe on the event id
seen.add(event.id);
setImmediate(() => handle(event));
});
function handle(event) {
switch (event.type) {
case 'order.created':
case 'order.status_changed':
console.log(event.data.externalOrderId, event.data.previousStatus, '->', event.data.status);
break;
case 'ping':
console.log('test delivery received');
break;
}
}
app.listen(3000);Test vector
| Input | Nilai |
|---|---|
| secret | whsec_test |
| t | 1700000000 |
| body | {"a":1} |
| signed string | 1700000000.{"a":1} |
| v1 | 38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789 |
verifySignature('whsec_test', 't=1700000000,v1=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789', '{"a":1}', 1700000000);
// => trueSemantik pengiriman
- Balas dengan 2xx apa pun dalam 10 detik. Selain itu (status lain, timeout, error koneksi) dihitung sebagai percobaan gagal. Akui dulu, lalu proses secara asinkron.
- Redirect tidak diikuti; 3xx dihitung gagal. Daftarkan URL final.
- Pengiriman bersifat at-least-once: event yang sama bisa tiba lebih dari sekali. Buat handler yang idempotent dan lakukan dedupe dengan
idenvelope. - Urutan tidak dijamin. Pakai
status/previousStatusatau ambil ulang resource daripada mengandalkan urutan kedatangan. - Percobaan yang gagal diulang, total 8 percobaan, dengan exponential backoff mulai dari 30 detik.
- Setelah 20 pengiriman gagal berturut-turut (semua percobaan habis), endpoint dinonaktifkan. Pengirimannya tetap ada di log; aktifkan lagi endpoint di konsol dan kirim ulang yang terlewat. Satu pengiriman yang berhasil me-reset penghitungnya.
- Konsol menampilkan setiap pengiriman beserta status code dan potongan response; Kirim ulang mengirim satu pengiriman lagi dengan id event yang sama.