SignalLab / developer setup
Connect a website to SignalLab
Send signed website inquiries and other customer signals straight into the shared workspace queue.
Webhook endpoint
Send a signed POST request
Replace the example host with your deployed SignalLab origin. The singular path is the primary endpoint; the plural path remains available for compatibility.
- Primary
- POST https://your-app.example.invalid/api/webhooks/website-signal
- Compatibility
- POST https://your-app.example.invalid/api/webhooks/website-signals
1 / Secret
Create and copy the secret
Generate a managed secret below, then copy it once into the website producer's server-only WEBSITE_SIGNALS_WEBHOOK_SECRET environment setting. SignalLab stores the managed secret encrypted and uses it to verify requests.
WEBSITE_SIGNALS_WEBHOOK_SECRET=FAKE_ONLY_REPLACE_WITH_A_REAL_SECRET_0123456789This value is deliberately fake. Keep the real value on your website producer's server only. Never use a NEXT_PUBLIC_* variable, put the secret in browser code, commit it, or send it in the request body. Existing SignalLab environment configuration remains a compatibility fallback until a managed secret is generated.
SignalLab encrypts managed secrets using a key derived from its server-side BETTER_AUTH_SECRET. Keep that setting stable while managed secrets exist; changing it requires re-encrypting the stored secret first.
2 / Signature
Sign the request metadata and exact body bytes
Send content-type: application/json, x-website-timestamp as Unix seconds, a fresh UUID in x-website-request-id, and an x-website-signature header in this format:
sha256=<64 hexadecimal characters>
Calculate HMAC-SHA256 with the shared secret over UTF-8 ${timestamp}.{requestId}. followed by the unchanged raw JSON body bytes. Timestamps are accepted within an inclusive ±300-second window. Serialize the payload once, sign the metadata prefix plus those exact body bytes, and send the same timestamp, request ID, and body. Changing any signed value invalidates the signature.
3 / Payload
Accepted signal fields
Include all required keys, even when a nullable lead field has no value. Send only the documented keys. Nullable lead text that is blank is normalized to null.
| Field | Requirement | Accepted value |
|---|---|---|
| source | Required | String, trimmed, nonblank, up to 500 characters. |
| signalType | Required | WEBSITE_INQUIRY, REVIEW_ISSUE, INBOX_MESSAGE, or PASTED_CONVERSATION. |
| leadName | Required; nullable | String or null, up to 120 characters. Blank text becomes null. |
| leadEmail | Required; nullable | Valid email or null, up to 254 characters. Blank text becomes null. |
| leadPhone | Required; nullable | String or null, up to 40 characters. Blank text becomes null. |
| leadMessage | Required | String, trimmed, nonblank, up to 5,000 characters. |
| urgency | Optional; defaults to MEDIUM | LOW, MEDIUM, HIGH, or CRITICAL. A matching workspace urgency rule may adjust it. |
| followUpConsent | Optional; defaults to null | true, false, or null. true requires at least one permitted channel. |
| permittedChannels | Optional; defaults to [] | Array containing EMAIL and/or PHONE. Must be empty unless consent is true. |
When followUpConsent is true, include at least one of EMAIL or PHONE in permittedChannels. When consent is false or null, send an empty channel array.
4 / Example
Complete signed Node.js request
Run this on your website server, where the secret is available. Replace the reserved .invalid host with your real SignalLab origin. The example customer data uses reserved test values.
import { createHmac, randomUUID } from 'node:crypto';
const endpoint = 'https://your-app.example.invalid/api/webhooks/website-signal';
const secret = process.env.WEBSITE_SIGNALS_WEBHOOK_SECRET;
if (!secret || secret.length < 32) throw new Error('Webhook secret must be at least 32 characters.');
const payload = {
"source": "website-form: contact",
"signalType": "WEBSITE_INQUIRY",
"urgency": "MEDIUM",
"leadName": "Example Customer",
"leadEmail": "customer@example.test",
"leadPhone": null,
"leadMessage": "Please send an estimate for a sample repair.",
"followUpConsent": true,
"permittedChannels": [
"EMAIL"
]
};
const body = JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const requestId = randomUUID();
const signingPrefix = Buffer.from(`${timestamp}.${requestId}.`, 'utf8');
const rawBody = Buffer.from(body, 'utf8');
const digest = createHmac('sha256', secret).update(Buffer.concat([signingPrefix, rawBody])).digest('hex');
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-website-timestamp': timestamp,
'x-website-request-id': requestId,
'x-website-signature': `sha256=${digest}`,
},
body,
});
const result = await response.json();
if (!response.ok || result.accepted !== true) {
throw new Error(`SignalLab webhook returned ${response.status}.`);
}5 / Responses
What the endpoint returns
- 201 accepted
- Accepted; the response is { "accepted": true } and a new OPEN signal is created.
- 401 unauthorized
- The signature is missing, malformed, or does not match the signing input.
- 401 stale_request
- The signed timestamp is more than 300 seconds before or after SignalLab time.
- 409 request_replayed
- This request ID was already accepted. Reusing an ID never creates another signal.
- 503 webhook_unavailable
- No managed secret or legacy SignalLab environment secret is configured.
- 400 invalid_request
- The timestamp or UUID request ID is missing or malformed.
- 400 invalid_json / invalid payload
- The signed body is malformed JSON or does not match the payload contract.
- 500 persistence error
- SignalLab could not persist the signal.
Each newly accepted request ID creates one signal and event. SignalLab retains accepted IDs so replaying one through either endpoint returns 409 request_replayedwithout creating another signal.