Webhooks
Webhooks Overview
Receive real-time notifications when events occur in Api.co.id.
When an event happens (like receiving a message), we'll send an HTTP POST request to your configured URL with the event data.
How It Works
- Configure a webhook URL in your dashboard
- Select which events you want to receive
- We send POST requests to your URL when events occur
- Your server processes the event and responds with 200 OK
Webhook Events
Available webhook events you can subscribe to.
| Event | Description |
|---|---|
message.received | New incoming message |
message.sent | Message sent successfully |
message.delivered | Message delivered to recipient |
message.read | Message read by recipient |
message.failed | Message delivery failed |
Payload Format
{
"event": "message.received",
"timestamp": "2025-01-07T10:30:00Z",
"data": {
"message_id": "msg_xyz789",
"customer_id": "clxxx1234567890",
"customer_phone": "628123456789",
"channel": "whatsapp",
"direction": "inbound",
"message_type": "text",
"content": "Hello, I need help!",
"media_url": null
}
}
For media messages (image/video/audio/document/sticker), the payload also
includes a media_status field:
"ok" (media_url is a
usable re-hosted URL), "download_failed"
(we could not re-host the media), or "unsupported"
(Meta sent no downloadable media, e.g. view-once messages). Only trust
media_url when media_status is "ok". The field is absent for non-media messages.
Webhook Security
Verify webhook signatures to ensure requests are from Api.co.id.
All webhook requests include an HMAC-SHA256 signature in the
X-Webhook-Signature header.
Verify this signature in your server to ensure the request actually came from Api.co.id.
Important: Always verify the webhook signature before processing events. Without verification, anyone could send fake webhook payloads to your server.
How Signature Works
- When you create a webhook endpoint, we generate a unique
webhook_secret - For each event, we compute:
HMAC-SHA256(payload, webhook_secret) - The hex-encoded signature is sent in the
X-Webhook-Signatureheader - Your server recomputes the signature using the same secret and compares
- If they match, the request is authentic — process the event
- If they don't match, return
403 Forbidden
Where to Find Your Webhook Secret
Go to Developers → Webhooks
and click on your webhook endpoint. The secret is shown only once when you create the webhook — store it securely in your environment variables (e.g., WEBHOOK_SECRET).
Verification Examples
- Node.js (Express)
- Python (Flask)
- PHP (Laravel)
- Go
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signature, secret) {
if (!signature) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
} catch {
return false;
}
}
// IMPORTANT: use express.raw() to get the unparsed body
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'];
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
return res.status(403).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
// Process the event based on event.event_type
console.log('Received event:', event.event_type);
// Always return 200 quickly to avoid retries
res.status(200).send('OK');
}
);
import hmac
import hashlib
from flask import Flask, request, abort
import os
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
if not signature:
return False
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# Use compare_digest to prevent timing attacks
return hmac.compare_digest(expected, signature)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature', '')
raw_body = request.get_data() # IMPORTANT: get raw bytes, not parsed JSON
if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
abort(403, description='Invalid signature')
event = request.get_json()
# Process the event
print(f"Received event: {event['event_type']}")
return 'OK', 200
<?php
// In a controller method or middleware
public function handleWebhook(\Illuminate\Http\Request $request)
{
$signature = $request->header('X-Webhook-Signature');
$rawBody = $request->getContent(); // IMPORTANT: raw body, not parsed
$secret = env('WEBHOOK_SECRET');
if (!$this->verifySignature($rawBody, $signature, $secret)) {
return response()->json(['error' => 'Invalid signature'], 403);
}
$event = json_decode($rawBody, true);
// Process event based on $event['event_type']
return response('OK', 200);
}
private function verifySignature(string $body, ?string $signature, string $secret): bool
{
if (empty($signature)) return false;
$expected = hash_hmac('sha256', $body, $secret);
// Use hash_equals to prevent timing attacks
return hash_equals($expected, $signature);
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifySignature(body []byte, signature, secret string) bool {
if signature == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal uses constant-time comparison
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
signature := r.Header.Get("X-Webhook-Signature")
secret := os.Getenv("WEBHOOK_SECRET")
if !verifySignature(body, signature, secret) {
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// Parse and process event
// ...
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
http.ListenAndServe(":8080", nil)
}
Common Pitfalls
❌ Comparing parsed JSON instead of raw body
The signature is computed on the EXACT raw bytes we send. If you parse the body to JSON and re-stringify it, even tiny formatting differences (whitespace, key order) will break the signature. Always use the raw body.
❌ Using == or === to compare signatures
Use constant-time comparison (timingSafeEqual,
hmac.compare_digest,
hash_equals,
hmac.Equal) to prevent timing attacks.
❌ Storing webhook secret in code
Always store the webhook secret in environment variables, never commit it to git.
❌ Returning slow responses
Return 200 OK within 5 seconds.
For heavy processing, queue the event and process asynchronously — otherwise we'll retry the delivery.
Test Your Verification
Once you've implemented signature verification:
- Go to Developers → Webhooks
- Click on your webhook endpoint
- Click the "Test Webhook" button
- We'll send a test event with a valid signature — your server should return
200 OK
List Webhooks
List your webhook endpoints and their status. Use this to check whether a webhook was auto-disabled after repeated delivery failures.
GET /api/v1/public/webhooks
A webhook is automatically disabled after 10 consecutive delivery failures (one
successful delivery resets the counter). When disabled,
is_active is
false and
disabled_at /
disable_reason are set. Re-enable it
via the Enable Webhook endpoint.
Example Request
- cURL
- JavaScript
- Python
curl -X GET "https://chat.api.co.id/api/v1/public/webhooks" \
-H "Authorization: Bearer YOUR_API_KEY"
const response = await fetch("https://chat.api.co.id/api/v1/public/webhooks", {
headers: { "Authorization": "Bearer YOUR_API_KEY" }
});
const { data } = await response.json();
const disabled = data.filter(w => !w.is_active);
import requests
res = requests.get(
"https://chat.api.co.id/api/v1/public/webhooks",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
webhooks = res.json()["data"]
Response
{
"success": true,
"data": [
{
"id": "cmxxx1234567890",
"name": "n8n production",
"url": "https://example.com/webhook",
"events": ["message.received"],
"channels": ["whatsapp"],
"is_active": false,
"status": "disabled",
"failure_count": 10,
"last_failed_at": "2026-06-24T03:00:00.000Z",
"disabled_at": "2026-06-24T03:05:00.000Z",
"disable_reason": "Auto-disabled after 10 consecutive delivery failures",
"created_at": "2026-06-01T00:00:00.000Z",
"updated_at": "2026-06-24T03:05:00.000Z"
}
]
}
Get Webhook
Get the status of a single webhook endpoint by ID.
GET /api/v1/public/webhooks/:id
URL Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | Webhook endpoint ID (from List Webhooks). |
Example Request
curl -X GET "https://chat.api.co.id/api/v1/public/webhooks/WEBHOOK_ID" \
-H "Authorization: Bearer YOUR_API_KEY"
Enable Webhook
Re-enable a webhook endpoint that was disabled (e.g. auto-disabled after repeated delivery failures). Also resets failure_count and clears disabled_at / disable_reason.
POST /api/v1/public/webhooks/:id/enable
URL Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | Webhook endpoint ID to enable. |
Example Request
- cURL
- JavaScript
- Python
curl -X POST "https://chat.api.co.id/api/v1/public/webhooks/WEBHOOK_ID/enable" \
-H "Authorization: Bearer YOUR_API_KEY"
const response = await fetch(
"https://chat.api.co.id/api/v1/public/webhooks/" + webhookId + "/enable",
{ method: "POST", headers: { "Authorization": "Bearer YOUR_API_KEY" } }
);
const { data } = await response.json();
console.log(data.is_active); // true
import requests
res = requests.post(
f"https://chat.api.co.id/api/v1/public/webhooks/{webhook_id}/enable",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
print(res.json())
Response
{
"success": true,
"data": {
"id": "cmxxx1234567890",
"name": "n8n production",
"url": "https://example.com/webhook",
"events": ["message.received"],
"channels": ["whatsapp"],
"is_active": true,
"status": "active",
"failure_count": 0,
"last_failed_at": "2026-06-24T03:00:00.000Z",
"disabled_at": null,
"disable_reason": null,
"created_at": "2026-06-01T00:00:00.000Z",
"updated_at": "2026-06-24T04:00:00.000Z"
},
"message": "Webhook endpoint enabled. Failure count has been reset."
}
Try it out
Press Try it and paste your API key (kc_live_...) into the API key (Bearer) field. Get the key from API Keys. Requests run against your live account, so endpoints that send, create, or delete act on real data.
- GET/api/v1/public/webhooksList Webhooks
- GET/api/v1/public/webhooks/{id}Get Webhook
- POST/api/v1/public/webhooks/{id}/enableEnable Webhook