Files
Duy /zuey/andGitHub ddf8e1099f feat(webhooks): HTTP webhooks to trigger agents with HMAC auth + durable callbacks (#2)
* feat(webhooks): HTTP webhooks to trigger agents with HMAC auth and durable callbacks

Add multi-tenant HTTP webhook endpoints for agent triggering:
- /v1/webhooks/message: send messages to channels
- /v1/webhooks/llm: sync/async LLM prompts with HMAC-signed callbacks
- HMAC-256 + bearer token authentication
- Rate limiting and tenant isolation
- Durable callback worker with exponential backoff
- PG 000056 + SQLite schema v25 migrations
- Unit + integration tests, P0 tenant isolation invariants
- Channel media capability helpers for attachment routing
- Comprehensive webhook documentation and i18n strings

* fix(webhooks): address post-review findings (K1-K10)

Comprehensive post-merge fixes addressing 10 blocking code review issues
and 2 adversarial re-audit findings in webhook-agent-triggering feature:

K1: Fix auth middleware tenant context lookup sequencing — move
    tenant context injection before authenticate() call to prevent
    unscoped secret lookups.

K2: Canonicalize JSON payload format for jsonb compatibility across
    PostgreSQL and SQLite — ensure consistent serialization without
    whitespace variance to prevent hash mismatches.

K3: Add fail-closed JSON parsing in body hash extraction with explicit
    error handling for malformed payloads before HMAC verification.

K4: Fix worker queue wedge by properly draining slot reservations
    when delivery succeeds, preventing permanent slot occupancy.

K5: Implement lease-token optimistic concurrency control to prevent
    duplicate webhook delivery under high concurrency or retry storms.

K6: Add AES-256-GCM encrypted secret storage at rest with fail-fast
    skip-mount when GOCLAW_ENCRYPTION_KEY environment variable unset.

K7: Implement IP allowlist enforcement supporting both CIDR ranges
    and exact IP matching with proper X-Forwarded-For parsing.

K8: Add HMAC replay nonce cache (5min expiry, non-blocking async flush)
    to prevent request replay attacks on webhook handler.

K9: Fix invariant test schema selection — replace hardcoded assumption
    with explicit schema name from config to support multi-schema testing.

K10: Consolidate rate limiters into single shared instance to prevent
     per-endpoint limiter starvation and ensure fair rate limiting.

New database migrations:
- 000057: webhook_calls.lease_token for optimistic concurrency
- 000058: webhooks.encrypted_secret_key for AES-256-GCM encryption

New i18n keys: MsgWebhookIPDenied, MsgWebhookEncryptionUnavailable
(with English, Vietnamese, Chinese translations).

New modules:
- internal/http/webhooks_payload.go: JSON canonicalization + body hash
- internal/http/webhooks_nonce.go: Replay nonce cache implementation
- internal/http/webhooks_idempotency_test.go: Integration tests

Documentation updates:
- docs/webhooks.md: §13-14 security sections, encryption flow
- docs/00-architecture-overview.md: webhook subsystem security overview
- docs/codebase-summary.md: webhook security patterns
- docs/project-changelog.md: webhook fixes changelog

Test coverage: 53 webhook tests + 4 P0 invariant tests all passing.
No tenant isolation violations. All security gates enforced.

* docs(journals): webhook feature ship + fix cycle entries

* fix(webhooks): address Claude review findings

- webhooks_llm.go: remove misleading ptr() helper; use &completedAt
  pattern for error-path audit rows (matches success path)
- webhooks_auth.go: wrap TouchLastUsed context in WithoutCancel so
  background DB update isn't cancelled when HTTP response completes
- store GetByIDUnscoped (PG+SQLite): add NOT revoked / revoked = 0
  filter for defense-in-depth parity with GetByHashUnscoped
- webhooks/sign.go: fix package doc — HMAC key is raw plaintext
  secret bytes, not hex-decoded SHA-256
- webhooks_admin.go: check auth before encKey guard to avoid leaking
  config state to unauthenticated callers
- webhooks_ratelimit.go: two-phase Load→LoadOrStore to avoid per-call
  entry allocation on the hot path

* docs(webhooks): fix Sign() function doc to match actual key input

Function-level comment still referenced hex-decoded SecretHash after
the package-level doc was corrected. Align with actual caller usage
([]byte(rawSecret)).

* fix(webhooks): use WithoutCancel for worker execute DB updates

Terminal status writes in execute() ran through the worker main-loop
ctx, which is cancelled on graceful shutdown. If the outbound send
completed but the status update raced with shutdown, the row stayed
in 'running' and got re-delivered via reclaimStale. WithoutCancel
lets the DB write survive worker cancellation while preserving
propagated values (tenant ID, etc.).

* fix(webhooks): move tctx init before panic defer in worker execute

Panic recovery called updateRetry with raw ctx (no tenant ID), making
requireTenantID fail and the reset-to-retry DB write silently drop.
Row stayed 'running' until reclaimStale (~90s delay). Init tctx first
so defer closure captures tenant-scoped non-cancellable context.

* fix(webhooks): pass tenant-scoped tctx to invokeAgent in worker

execute() was passing the raw worker-loop ctx (no tenant ID) to
invokeAgent → router.Get → PGAgentStore.GetByID. GetByID reads
TenantIDFromContext which returned uuid.Nil, making every lookup
return 'agent not found'. Async LLM webhook calls silently failed
all retries. Pass tctx (already tenant-scoped + WithoutCancel) so
the router resolves the agent correctly.

* fix(tests): resolve integration test compile errors

- Remove duplicate contains() in mcp_grant_revoke_test.go (already
  defined in tts_gemini_live_test.go)
- Update webhooks_admin_test.go RotateSecret call to match current
  5-arg signature (newSecretHash, newPrefix, newEncryptedSecret)

* fix(webhooks): default nil scopes/ip_allowlist to empty slice in Create

PG columns are NOT NULL DEFAULT '{}'. Explicit NULL from pqStringArray(nil)
violated the constraint, breaking TestWebhookAdminCRUD/TenantIsolation.
Coerce nil slices to empty []string{} so the default applies at the DB layer.

* chore: trigger CI on digitopvn/goclaw fork

* ci: retrigger workflows

* fix(webhooks): renumber migrations to 000059-000061 for merge train
2026-05-11 13:29:24 +07:00

736 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Webhook API Reference
> **Authoritative integration guide.** Describes inbound auth, endpoint contracts, outbound callback semantics, retry schedule, and security constraints.
## Table of Contents
1. [Overview](#1-overview)
2. [Admin CRUD](#2-admin-crud)
3. [Authentication](#3-authentication)
4. [Endpoint: POST /v1/webhooks/llm](#4-post-v1webhooksllm)
5. [Endpoint: POST /v1/webhooks/message](#5-post-v1webhooksmessage)
6. [Idempotency](#6-idempotency)
7. [Outbound Callbacks](#7-outbound-callbacks)
8. [Channel Capability Matrix](#8-channel-capability-matrix)
9. [Rate Limits](#9-rate-limits)
10. [Edition Differences](#10-edition-differences)
11. [Security](#11-security)
12. [HMAC Receiver Examples](#12-hmac-receiver-examples)
13. [Audit Payload Shape](#13-audit-payload-shape-webhook_callsrequest_payload)
14. [Encryption at Rest](#14-encryption-at-rest)
---
## 1. Overview
GoClaw webhooks let external systems trigger agents or deliver messages through connected channels. Two webhook kinds exist:
| Kind | Endpoint | Purpose | Editions |
|------|----------|---------|----------|
| `llm` | `POST /v1/webhooks/llm` | Invoke an agent with a user prompt (sync or async) | Standard + Lite |
| `message` | `POST /v1/webhooks/message` | Send a message to a user on a channel | Standard only |
Webhooks are tenant-scoped registry entries. Admins create them via the CRUD API; callers use the returned bearer token or HMAC signing key to authenticate inbound requests.
---
## 2. Admin CRUD
All admin endpoints require tenant-admin role. Bearer token authentication via `Authorization: Bearer <admin-token>`.
### Create — `POST /v1/webhooks`
```json
{
"name": "my-integration",
"kind": "llm",
"agent_id": "<uuid>",
"require_hmac": false,
"localhost_only": false,
"rate_limit_per_min": 60,
"scopes": [],
"ip_allowlist": []
}
```
Fields:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | Max 100 chars |
| `kind` | string | yes | `"llm"` or `"message"` |
| `agent_id` | UUID | for `llm` kind | Agent to invoke |
| `channel_id` | UUID | optional | Pin webhook to a specific channel instance (message kind) |
| `require_hmac` | bool | no | Force HMAC-only auth (disable bearer) |
| `localhost_only` | bool | no | Restrict callers to 127.0.0.1/::1. Auto-set on Lite edition |
| `rate_limit_per_min` | int | no | Per-webhook cap; 0 = use tenant default |
| `scopes` | []string | no | Reserved for future scope enforcement |
| `ip_allowlist` | []string | no | Allowlist of IPs or CIDR ranges. Empty = allow all. See [IP Allowlist](#ip-allowlist) |
**Response — 201 Created**
```json
{
"id": "<uuid>",
"tenant_id": "<uuid>",
"agent_id": "<uuid>",
"name": "my-integration",
"kind": "llm",
"secret_prefix": "wh_ABCD",
"secret": "wh_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGH",
"hmac_signing_key": "a3f4...hex64chars",
"scopes": [],
"rate_limit_per_min": 60,
"ip_allowlist": [],
"require_hmac": false,
"localhost_only": false,
"created_at": "2026-04-21T12:00:00Z"
}
```
**`secret` and `hmac_signing_key` are returned exactly once — on create and rotate. Store them securely; they cannot be retrieved again.**
- `secret` — raw bearer token. Send as `Authorization: Bearer wh_...`
- `hmac_signing_key``hex(SHA-256(secret))`. Used as the HMAC signing key for `X-GoClaw-Signature`. To sign: `HMAC_SHA256(key=hex.Decode(hmac_signing_key), payload="{ts}.{body}")`
### List — `GET /v1/webhooks`
Query params: `agent_id=<uuid>` (optional filter).
Returns array of webhook objects. `secret` and `hmac_signing_key` are **not** included.
### Get — `GET /v1/webhooks/{id}`
Returns full webhook object (no secret).
### Update — `PATCH /v1/webhooks/{id}`
Partial update. All fields optional. Cannot change `kind`.
```json
{
"name": "new-name",
"require_hmac": true,
"localhost_only": false
}
```
### Rotate Secret — `POST /v1/webhooks/{id}/rotate`
Generates a new secret immediately. **No grace window** — the old secret is invalidated the moment rotate completes. Coordinate with callers before rotating.
**Response — 200 OK**
```json
{
"id": "<uuid>",
"secret": "wh_NEW...",
"hmac_signing_key": "newhex...",
"secret_prefix": "wh_NEWX"
}
```
### Revoke — `DELETE /v1/webhooks/{id}`
Marks the webhook as revoked. All subsequent inbound requests with its secret return `401`. Action is irreversible.
---
## 3. Authentication
Two authentication modes. The webhook row's `require_hmac` field determines which are accepted.
### 3.1 Bearer Auth
```
Authorization: Bearer wh_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGH
```
The gateway SHA-256 hashes the token and looks up `secret_hash` in the database. Constant-time comparison prevents timing oracle attacks.
Bearer auth is **disabled** when `require_hmac=true` on the webhook row.
### 3.2 HMAC Auth
Recommended for Standard edition integrations. Provides both authentication and payload integrity.
**Required headers:**
```
X-Webhook-Id: <webhook-uuid>
X-GoClaw-Signature: t=<unix_seconds>,v1=<hmac_hex>
Content-Type: application/json
```
**Signing algorithm:**
```
signing_key = hex.Decode(hmac_signing_key) // decode the hex field to raw bytes
payload = "{unix_ts}.{request_body_bytes}"
signature = HMAC_SHA256(key=signing_key, data=payload)
header = "t={unix_ts},v1={hex(signature)}"
```
**Timestamp skew:** The gateway rejects requests where `|now - t| > 300 seconds`. Ensure your clock is synchronized (NTP).
**Key contract:** `hmac_signing_key` = `hex(SHA-256(raw_secret))`. The signing key is the **decoded bytes** of this hex string. The raw secret is never stored — only its hash.
### HMAC Replay Protection
After a valid HMAC signature is accepted, the gateway records `sha256(tenant_id + "|" + signature_hex)` in an in-memory nonce cache with a 320-second TTL (> 2× skew window). Any request replaying the same signature within the window is rejected with HTTP 401 and logged as `security.webhook.hmac_replay`.
**Single-node caveat:** The nonce cache is per-process and not distributed. In a multi-node deployment a replay could succeed on a different node. This is an accepted trade-off for the current single-process gateway architecture.
### IP Allowlist
When `ip_allowlist` is non-empty, the gateway checks the request's source IP (from `RemoteAddr`) against every entry after successful auth. Each entry can be:
- A single IP address: `"1.2.3.4"`, `"::1"`
- A CIDR range: `"10.0.0.0/8"`, `"2001:db8::/32"`
An empty `ip_allowlist` (the default) allows requests from any source — back-compat with existing webhooks.
Rejected requests return HTTP 403 and are logged as `security.webhook.ip_denied`.
**Proxy note:** `X-Forwarded-For` is **not** trusted — only `RemoteAddr` is used. If your gateway sits behind a reverse proxy, ensure the proxy is configured to terminate TLS and handle allowlist enforcement itself, or accept that `RemoteAddr` will be the proxy IP.
---
## 4. POST /v1/webhooks/llm
Triggers an agent with an input prompt. Available in all editions.
**Auth:** Bearer or HMAC (per webhook `require_hmac` setting). Webhook must have `kind="llm"`.
### Request
```json
{
"input": "Summarize the latest metrics",
"session_key": "user-123-session",
"user_id": "ext-user-456",
"model": "claude-opus-4-5",
"mode": "sync",
"callback_url": "",
"metadata": {}
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `input` | string or array | yes | Plain string, or `[{role, content}]` array |
| `session_key` | string | no | Stable key for multi-turn conversation continuity |
| `user_id` | string | no | External user identifier for scoping |
| `model` | string | no | Per-request model override |
| `mode` | string | no | `"sync"` (default) or `"async"` |
| `callback_url` | string | required if async | HTTPS URL for delivery. Validated against SSRF policy |
| `metadata` | object | no | Echoed to callback payload (max 8 KB) |
**Input formats:**
```json
// Plain string
"input": "Hello agent"
// Message array
"input": [
{"role": "system", "content": "You are a concise assistant"},
{"role": "user", "content": "List 3 key metrics"}
]
```
### Sync Response — 200 OK
```json
{
"call_id": "<uuid>",
"agent_id": "<uuid>",
"output": "Here are the metrics: ...",
"usage": {
"prompt_tokens": 150,
"completion_tokens": 200,
"total_tokens": 350
},
"finish_reason": "stop"
}
```
Sync mode times out at **30 seconds**. On timeout: `504 Gateway Timeout` with `webhook.llm_timeout`.
### Async Response — 202 Accepted
```json
{
"call_id": "<uuid>",
"status": "queued"
}
```
The agent runs asynchronously. Results are delivered via outbound callback (see [Section 7](#7-outbound-callbacks)).
### Error Responses
| Status | Code | When |
|--------|------|------|
| 400 | `invalid_request` | Missing `input`, bad `mode`, missing `callback_url` for async |
| 401 | — | Auth failure (bearer invalid, HMAC mismatch, revoked, HMAC replay) |
| 403 | `unauthorized` | `localhost_only` violation, IP allowlist denial, kind mismatch, tenant mismatch |
| 404 | `not_found` | Agent not found |
| 429 | — | Rate limit exceeded; `Retry-After: 60` header set |
| 503 | — | Webhook processing lane at capacity |
| 504 | — | LLM timeout (sync mode only) |
---
## 5. POST /v1/webhooks/message
Sends a message to a user on a connected channel. **Standard edition only** — not available on Lite.
**Auth:** Bearer or HMAC (per webhook `require_hmac` setting). Webhook must have `kind="message"`.
### Request
```json
{
"channel_name": "telegram-prod",
"chat_id": "123456789",
"content": "Hello from the integration!",
"media_url": "https://example.com/image.jpg",
"media_caption": "Optional caption",
"fallback_to_text": false
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `channel_name` | string | yes (unless webhook has bound `channel_id`) | Channel instance name |
| `chat_id` | string | yes | Channel-specific recipient ID |
| `content` | string | yes (unless `media_url`) | Text body; max 16 KB |
| `media_url` | string | no | HTTPS URL to media file. SSRF-guarded + HEAD-probed |
| `media_caption` | string | no | Caption for media |
| `fallback_to_text` | bool | no | If true, send text-only when channel can't handle media |
### Response — 200 OK
```json
{
"call_id": "<uuid>",
"status": "sent",
"channel_name": "telegram-prod",
"chat_id": "123456789",
"warning": ""
}
```
`warning` is set to `"media_not_supported_fallback_text"` when `fallback_to_text=true` and media was dropped.
### Error Responses
| Status | Code | When |
|--------|------|------|
| 400 | `invalid_request` | Missing `chat_id`, `content`, SSRF-blocked `media_url` |
| 403 | `unauthorized` | Channel belongs to different tenant |
| 404 | `not_found` | Channel instance not found |
| 415 | `invalid_request` | MIME type denied for media |
| 429 | — | Rate limit exceeded |
| 501 | `invalid_request` | Channel does not support media and `fallback_to_text=false` |
---
## 6. Idempotency
All webhook endpoints support idempotency via the `Idempotency-Key` header.
```
Idempotency-Key: <opaque-string-max-255-chars>
```
**Semantics:**
- First request with a given key: processed normally.
- Subsequent requests with the **same key and identical body**: return the cached response immediately with `200 OK` (no duplicate processing).
- Subsequent requests with the **same key but different body**: return `409 Conflict` with `webhook.idempotency_conflict`.
- Keys expire after 24 hours (implementation: `webhook_calls` table TTL).
**Recommendation:** Use a UUID or hash of request content as the key. Re-send the exact same request body on retry.
---
## 7. Outbound Callbacks
Async LLM calls (`mode=async`) deliver results to the `callback_url` via HTTP POST.
### Delivery Guarantee
Callbacks are **at-least-once**. Receivers must be idempotent.
### Stable Headers
Every delivery attempt carries:
```
X-Webhook-Delivery-Id: <uuid> -- stable across retries
X-Webhook-Signature: t=<unix>,v1=<hex> -- recomputed per attempt (timestamp differs)
Content-Type: application/json
User-Agent: goclaw-webhook/1
```
`X-Webhook-Delivery-Id` is stable for all retry attempts of the same call. Receivers **SHOULD** deduplicate by this ID within a window of at least 24 hours.
`X-Webhook-Signature` uses the **same HMAC algorithm** as inbound auth. Verify with the `hmac_signing_key` from the create response.
### Payload
```json
{
"call_id": "<uuid>",
"delivery_id": "<uuid>",
"agent_id": "<uuid>",
"status": "done",
"output": "Agent response text...",
"usage": {
"prompt_tokens": 150,
"completion_tokens": 200,
"total_tokens": 350
},
"metadata": {},
"error": ""
}
```
`status` is `"done"` on success, `"failed"` on agent error. `error` is non-empty on failure.
### Retry Schedule
| Attempt | Delay (±10% jitter) |
|---------|---------------------|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
After 5 failed attempts the row moves to `status=dead`. No further retries.
**`Retry-After` header:** If the receiver returns `429` with a `Retry-After` header, the worker respects it (capped at 6 hours).
**Permanent failure:** `4xx` responses (except `429`) are treated as permanent — no retry.
**Success:** Any `2xx` response marks the delivery as done.
### Verifying Outbound Signatures
```go
// Go — verify X-Webhook-Signature on your callback endpoint
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func verifyWebhookSignature(r *http.Request, body []byte, hmacSigningKey string) error {
sigHeader := r.Header.Get("X-Webhook-Signature")
// Parse "t=<unix>,v1=<hex>"
var ts int64
var sigHex string
for _, part := range strings.Split(sigHeader, ",") {
if strings.HasPrefix(part, "t=") {
ts, _ = strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
}
if strings.HasPrefix(part, "v1=") {
sigHex = strings.TrimPrefix(part, "v1=")
}
}
if ts == 0 || sigHex == "" {
return fmt.Errorf("missing signature header fields")
}
// Verify timestamp skew
if abs(time.Now().Unix()-ts) > 300 {
return fmt.Errorf("timestamp skew too large")
}
// Decode HMAC key from hex
key, err := hex.DecodeString(hmacSigningKey)
if err != nil {
return err
}
// Recompute HMAC
payload := append([]byte(fmt.Sprintf("%d.", ts)), body...)
mac := hmac.New(sha256.New, key)
mac.Write(payload)
expected := mac.Sum(nil)
// Decode received sig
received, err := hex.DecodeString(sigHex)
if err != nil || !hmac.Equal(expected, received) {
return fmt.Errorf("signature mismatch")
}
return nil
}
```
---
## 8. Channel Capability Matrix
Relevant for `POST /v1/webhooks/message` with `media_url`.
| Channel Type | Text | Media |
|--------------|------|-------|
| `telegram` | yes | yes |
| `discord` | yes | yes |
| `whatsapp` | yes | yes |
| `feishu` | yes | yes |
| `slack` | yes | yes |
| `zalo_personal` | yes | yes |
| `pancake` | yes | yes |
| `facebook` | yes | yes |
| `zalo_oa` | yes | no |
When `media_url` is sent to a non-media-capable channel:
- `fallback_to_text=true` → text content delivered, `warning` field set
- `fallback_to_text=false` (default) → `501 Not Implemented`
---
## 9. Rate Limits
Rate limiting is two-tier:
| Tier | Cap | Notes |
|------|-----|-------|
| Per-webhook | `rate_limit_per_min` field (0 = disabled) | Configured per webhook row |
| Per-tenant | Platform default (configurable) | Applies across all webhooks for a tenant |
Both tiers must pass. If either rejects the request, `429 Too Many Requests` is returned with `Retry-After: 60`.
---
## 10. Edition Differences
| Feature | Standard | Lite |
|---------|----------|------|
| `/v1/webhooks/llm` | Available | Available (localhost_only forced) |
| `/v1/webhooks/message` | Available | Disabled |
| `localhost_only=false` | Configurable | Always true; cannot be unset |
| `kind="message"` webhook creation | Allowed | Rejected (403) |
On Lite, all webhooks are automatically created with `localhost_only=true` regardless of the request field. Attempting to unset `localhost_only` via PATCH returns `403`.
---
## 11. Security
### SSRF Protection
- `media_url` in message webhooks: validated against SSRF policy + HEAD-probed before fetch.
- `callback_url` in async LLM webhooks: validated at enqueue time and re-validated at delivery time (prevents DNS rebinding attacks).
- Log event: `security.webhook.ssrf_blocked` / `security.webhook.callback_ssrf_blocked`.
### Secret Storage
Secrets are never stored in plaintext. Only `SHA-256(secret)` is kept in the database. Secrets are never logged.
### HMAC Timestamp Skew
Requests with `|now - t| > 300 seconds` are rejected immediately (before any DB lookup) to prevent replay attacks.
### Tenant Isolation
- Agent must belong to the webhook's tenant.
- Channel must belong to the webhook's tenant (or be a legacy config-based channel).
- Log events: `security.webhook.tenant_mismatch`, `security.webhook.tenant_leak_attempt`.
### Secret Rotation
**No grace window.** The old secret is invalidated immediately when `POST /v1/webhooks/{id}/rotate` completes. Coordinate with callers before rotating in production.
---
## 12. HMAC Receiver Examples
### curl (signing with openssl)
```bash
WEBHOOK_HMAC_KEY="a3f4...your_hmac_signing_key_hex"
WEBHOOK_ID="your-webhook-uuid"
BODY='{"input":"hello","mode":"sync"}'
TS=$(date +%s)
PAYLOAD="${TS}.${BODY}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -mac HMAC \
-macopt "hexkey:${WEBHOOK_HMAC_KEY}" | awk '{print $2}')
curl -X POST https://example.com/v1/webhooks/llm \
-H "Content-Type: application/json" \
-H "X-Webhook-Id: ${WEBHOOK_ID}" \
-H "X-GoClaw-Signature: t=${TS},v1=${SIG}" \
-d "$BODY"
```
### curl (bearer auth)
```bash
curl -X POST https://example.com/v1/webhooks/llm \
-H "Authorization: Bearer wh_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGH" \
-H "Content-Type: application/json" \
-d '{"input":"hi","mode":"sync"}'
```
### Node.js (HMAC signing)
```js
const crypto = require('crypto');
function signWebhookRequest(body, hmacSigningKeyHex) {
const ts = Math.floor(Date.now() / 1000);
const keyBytes = Buffer.from(hmacSigningKeyHex, 'hex');
const payload = Buffer.concat([
Buffer.from(`${ts}.`),
Buffer.isBuffer(body) ? body : Buffer.from(body),
]);
const sig = crypto.createHmac('sha256', keyBytes).update(payload).digest('hex');
return { ts, signature: `t=${ts},v1=${sig}` };
}
// Usage
const body = JSON.stringify({ input: 'hello', mode: 'sync' });
const { signature } = signWebhookRequest(body, process.env.WEBHOOK_HMAC_KEY);
await fetch('https://example.com/v1/webhooks/llm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Id': process.env.WEBHOOK_ID,
'X-GoClaw-Signature': signature,
},
body,
});
```
### Python (HMAC signing)
```python
import hashlib
import hmac
import json
import time
import requests
def sign_webhook(body: bytes, hmac_signing_key_hex: str) -> str:
ts = int(time.time())
key = bytes.fromhex(hmac_signing_key_hex)
payload = f"{ts}.".encode() + body
sig = hmac.new(key, payload, hashlib.sha256).hexdigest()
return f"t={ts},v1={sig}"
body = json.dumps({"input": "hello", "mode": "sync"}).encode()
signature = sign_webhook(body, os.environ["WEBHOOK_HMAC_KEY"])
requests.post(
"https://example.com/v1/webhooks/llm",
headers={
"Content-Type": "application/json",
"X-Webhook-Id": os.environ["WEBHOOK_ID"],
"X-GoClaw-Signature": signature,
},
data=body,
)
```
---
## 13. Audit Payload Shape (`webhook_calls.request_payload`)
Every call creates a row in `webhook_calls` with a `request_payload` column (`jsonb` on PostgreSQL, `TEXT` on SQLite). The canonical shape is:
```json
{
"body_hash": "<sha256-hex-64-chars>",
"meta": { ... handler-specific fields ... }
}
```
### `body_hash`
SHA-256 hex digest of the raw request body bytes. Used by the idempotency subsystem to detect body-mismatch replays (same `Idempotency-Key`, different body → 409 Conflict).
### `meta` by handler
**`POST /v1/webhooks/llm`** — meta mirrors the decoded request fields:
```json
{
"input": "<raw JSON — string or message array>",
"session_key": "optional-key",
"user_id": "optional-uid",
"model": "optional-override",
"mode": "sync",
"callback_url": "",
"metadata": null
}
```
**`POST /v1/webhooks/message`** — meta contains delivery context:
```json
{
"channel_name": "telegram-main",
"chat_id": "123456789",
"has_media": false
}
```
### Notes
- `body_hash` is always exactly 64 lowercase hex characters. Any stored value that does not match this format is treated as "no hash" by the idempotency checker (fail-closed).
- External consumers reading `request_payload` via SQL should parse it as JSON, not as raw bytes.
- Shape is stable across LLM and message handler calls — only `meta` contents differ.
---
## 14. Encryption at Rest
### Raw Secret Encryption
The webhook secret is encrypted at rest using AES-256-GCM, keyed by the environment variable `GOCLAW_ENCRYPTION_KEY` (required for webhook HMAC auth to work). Only the database stores encrypted secret material.
**Key contract (POST /v1/webhooks create/rotate response):**
```json
{
"secret": "wh_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGH",
"hmac_signing_key": "a3f4...hex64chars"
}
```
- `secret` — Raw bearer token in plaintext. Clients **must store securely** on their end; the gateway will not retrieve it again.
- `hmac_signing_key` — Derived as `hex(SHA-256(secret))`. This is also returned once and should be stored securely by clients.
**Database storage:**
- `webhooks.secret_hash` column: `SHA-256(secret)` in hex. Used for bearer auth lookups (constant-time comparison).
- `webhooks.encrypted_secret` column (PG/SQLite): AES-256-GCM encrypted raw secret. Used to support lease-token reclamation and idempotency recovery on stale calls.
- Environment variable `GOCLAW_ENCRYPTION_KEY` — required for webhook processing. Same key also encrypts LLM provider API keys. Format: base64-encoded 32-byte key.
**Migration notes:**
- PostgreSQL: Migration `000058` added `encrypted_secret` column.
- SQLite (Lite edition): Schema v28 includes encrypted secret support.
**DB compromise impact:**
A database-layer attacker with read-only access to `webhooks` table **cannot** derive the raw secret or `hmac_signing_key`:
- `secret_hash` alone does not reverse-engineer the secret (cryptographic hash).
- `encrypted_secret` requires `GOCLAW_ENCRYPTION_KEY` to decrypt (environment-only, not in database).
- Attackers gain no actionable HMAC material.
### Environment Variable Security
`GOCLAW_ENCRYPTION_KEY` must be:
- Stored securely (e.g., sealed in a secret manager, not in `config.json`).
- Same across all gateway instances in a cluster (standard multi-replica key).
- Rotated as part of incident response — rotation requires re-encrypting all webhook secrets (automated migration).
---