Skip to content
↑↓ navigate↵ openEsc close

Guides

Webhooks

TheComNet POSTs a signed JSON event to your HTTPS endpoint when orders, returns, stock, or chats change.

Setup

  1. In TheComNet open Pengaturan → Integrasi and add a webhook endpoint URL (up to 10 per tenant).
  2. Select the events to subscribe to.
  3. Copy the signing secret (whsec_…). It can be revealed again or rotated later; after rotation only the new secret signs.
  4. Press Kirim tes to send a ping event and check the delivery log.

Events

EventSent when
order.createdA new order arrived from a connected store. previousStatus is null.
order.status_changedAn order moved to another normalized status (paid, shipped, delivered, cancelled, …).
aftersale.createdA buyer opened a cancellation or return request. type and status are the marketplace’s own values.
aftersale.completedA return with goods sent back was completed.
inventory.stock_changedSellable stock (on hand minus safety stock, never below 0) of a master item changed.
chat.message_receivedA buyer sent a chat message. text is a short description for non-text messages.
pingYou press Kirim tes in the console. data is { "message": "Webhook test from TheComNet" }. Not subscribable.

Envelope

Every delivery has the same top-level shape:

FieldTypeMeaning
idstring (uuid)Event id. The same event delivered again (retry or Kirim ulang) keeps this id; dedupe on it.
typestringEvent type, also sent as x-thecomnet-event.
createdAtstring (date-time)When the event was fanned out to your endpoint (UTC).
tenantIdstring (uuid)Tenant the event belongs to.
dataobjectEvent-specific payload, below.
simulatedbooleanPresent 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 status and previousStatus are normalized statuses: pending_payment, processing, shipped, delivered, completed, cancelled, returned. Marketplace-specific statuses are on the order (marketplaceStatus); fetch it with GET /v1/orders/{orderId}.
  • inventory.stock_changed reason is 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

HeaderValue
content-typeapplication/json
user-agentTheComNet-Webhooks/1.0
x-thecomnet-eventEvent type, e.g. order.created.
x-thecomnet-deliveryDelivery id (one per endpoint and event). Stays the same across retries and Kirim ulang.
x-thecomnet-signaturet=<unix seconds>,v1=<hex>

Verifying signatures

  1. Parse x-thecomnet-signature into t (Unix seconds) and v1.
  2. Compute v1 = hex(HMAC-SHA256(secret, `${t}.${rawBody}`)) over the raw request body exactly as received. The secret is the whole whsec_… string.
  3. Compare with the received v1 in constant time.
  4. Reject the request if t is more than 5 minutes away from your clock (replay protection). Each attempt is signed with a fresh t.
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

InputValue
secretwhsec_test
t1700000000
body{"a":1}
signed string1700000000.{"a":1}
v138877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789
verifySignature('whsec_test', 't=1700000000,v1=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789', '{"a":1}', 1700000000);
// => true

Delivery 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/previousStatus or 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.