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_0123456789

This 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.

Loading saved secret status…

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.

Required and optional website signal payload fields
FieldRequirementAccepted value
sourceRequiredString, trimmed, nonblank, up to 500 characters.
signalTypeRequiredWEBSITE_INQUIRY, REVIEW_ISSUE, INBOX_MESSAGE, or PASTED_CONVERSATION.
leadNameRequired; nullableString or null, up to 120 characters. Blank text becomes null.
leadEmailRequired; nullableValid email or null, up to 254 characters. Blank text becomes null.
leadPhoneRequired; nullableString or null, up to 40 characters. Blank text becomes null.
leadMessageRequiredString, trimmed, nonblank, up to 5,000 characters.
urgencyOptional; defaults to MEDIUMLOW, MEDIUM, HIGH, or CRITICAL. A matching workspace urgency rule may adjust it.
followUpConsentOptional; defaults to nulltrue, false, or null. true requires at least one permitted channel.
permittedChannelsOptional; 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.