Guides
Webhooks
TheComNet POSTs a signed JSON event to your HTTPS endpoint when orders, returns, stock, or chats change.
Setup
- In TheComNet open Pengaturan → Integrasi and add a webhook endpoint URL (up to 10 per tenant).
- Select the events to subscribe to.
- Copy the signing secret (
whsec_…). It can be revealed again or rotated later; after rotation only the new secret signs. - Press Kirim tes to send a
pingevent and check the delivery log.
Events
| Event | Sent when |
|---|---|
order.created | A new order arrived from a connected store. previousStatus is null. |
order.status_changed | An order moved to another normalized status (paid, shipped, delivered, cancelled, …). |
aftersale.created | A buyer opened a cancellation or return request. type and status are the marketplace’s own values. |
aftersale.completed | A return with goods sent back was completed. |
inventory.stock_changed | Sellable stock (on hand minus safety stock, never below 0) of a master item changed. |
chat.message_received | A buyer sent a chat message. text is a short description for non-text messages. |
ping | You press Kirim tes in the console. data is { "message": "Webhook test from TheComNet" }. Not subscribable. |
Envelope
Every delivery has the same top-level shape:
| Field | Type | Meaning |
|---|---|---|
id | string (uuid) | Event id. The same event delivered again (retry or Kirim ulang) keeps this id; dedupe on it. |
type | string | Event type, also sent as x-thecomnet-event. |
createdAt | string (date-time) | When the event was fanned out to your endpoint (UTC). |
tenantId | string (uuid) | Tenant the event belongs to. |
data | object | Event-specific payload, below. |
simulated | boolean | Present only on simulated events, always 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?"
}
}Payload notes
- Order
statusandpreviousStatusare normalized statuses:pending_payment,processing,shipped,delivered,completed,cancelled,returned. Marketplace-specific statuses are on the order (marketplaceStatus); fetch it withGET /v1/orders/{orderId}. inventory.stock_changedreasonis the stock movement reason:adjust,set,order,cancel,receive,issue,transfer_in,transfer_out,stocktake,return.- Events carry ids and the changed fields only. Fetch the full resource from the API when you need more.
Simulated events
Press Simulasikan event under Pengaturan → Integrasi → Webhook (or call POST /v1/developer/sandbox/events with a session token) to queue a sample event of any type to every subscribed endpoint. It carries simulated: true and the usual signature. Ignore such events in production. See Sandbox: simulated events.
Request headers
| Header | Value |
|---|---|
content-type | application/json |
user-agent | TheComNet-Webhooks/1.0 |
x-thecomnet-event | Event type, e.g. order.created. |
x-thecomnet-delivery | Delivery id (one per endpoint and event). Stays the same across retries and Kirim ulang. |
x-thecomnet-signature | t=<unix seconds>,v1=<hex> |
Verifying signatures
- Parse
x-thecomnet-signatureintot(Unix seconds) andv1. - Compute
v1 = hex(HMAC-SHA256(secret, `${t}.${rawBody}`))over the raw request body exactly as received. The secret is the wholewhsec_…string. - Compare with the received
v1in constant time. - Reject the request if
tis more than 5 minutes away from your clock (replay protection). Each attempt is signed with a fresht.
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 | Value |
|---|---|
| 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);
// => trueDelivery semantics
- Respond with any 2xx within 10 seconds. Anything else (other status, timeout, connection error) is a failed attempt. Acknowledge first, then process asynchronously.
- Redirects are not followed; a 3xx counts as a failure. Register the final URL.
- Delivery is at-least-once: the same event can arrive more than once. Make handlers idempotent and dedupe on the envelope
id. - Order is not guaranteed. Use
status/previousStatusor re-fetch the resource rather than relying on arrival order. - Failed attempts are retried, 8 attempts in total, with exponential backoff starting at 30 seconds.
- After 20 consecutive failed deliveries (all attempts spent) the endpoint is disabled. Its deliveries stay in the log; re-enable the endpoint in the console and resend what you missed. A successful delivery resets the counter.
- The console shows every delivery with status code and response excerpt; Kirim ulang sends one again with the same event id.