What you’ll build
Signa webhook -> Cloudflare Worker -> Slack incoming webhook
(verifies signature) (posts to channel)
You need:
- A Slack incoming webhook URL.
- A Slack channel to post into.
- A Signa API key with
portfolios:manage (used to register the webhook endpoint).
- A Signa watch already created — see Watches.
Your Signa API key only needs to live wherever you call signa.webhooks.create. The Cloudflare Worker / Lambda below verifies signatures with the webhook secret and never calls the Signa API, so it does not need a Signa API key at runtime — only SIGNA_WEBHOOK_SECRET and SLACK_WEBHOOK_URL.
Cloudflare Worker
Save as worker.ts:
import { verifyWebhookSignature } from '@signa-so/sdk';
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
const body = await req.text();
const ok = verifyWebhookSignature(
{
'webhook-id': req.headers.get('webhook-id') ?? '',
'webhook-timestamp': req.headers.get('webhook-timestamp') ?? '',
'webhook-signature': req.headers.get('webhook-signature') ?? '',
},
body,
env.SIGNA_WEBHOOK_SECRET,
);
if (!ok) return new Response('invalid signature', { status: 401 });
const event = JSON.parse(body);
if (event.type !== 'alert.created') return new Response('ok', { status: 200 });
const alert = event.data;
await fetch(env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: alertToSlackMessage(alert) }),
});
return new Response('ok', { status: 200 });
},
};
interface Env {
SIGNA_WEBHOOK_SECRET: string;
SLACK_WEBHOOK_URL: string;
}
function alertToSlackMessage(alert: Record<string, any>): string {
const sev = (alert.deadline?.severity ?? alert.severity) as string;
const emoji = sev === 'critical' ? ':rotating_light:' : sev === 'high' ? ':warning:' : ':bell:';
// The `alert.created` body is self-contained: prefer the rich nested fields
// (alert.event.summary, alert.watch.name, alert.trademark.mark_text) and fall
// back to the always-present flat IDs if the rich half is absent.
const windowStatus = alert.deadline?.opposition_window_status ?? alert.opposition_window_status;
const window = windowStatus ? ` (window: ${windowStatus})` : '';
const headline = alert.event?.summary ?? alert.event_type;
const watchLabel = alert.watch?.name ?? alert.watch_id;
const markLabel = alert.trademark?.mark_text ?? alert.trademark_id;
const mustActBy = alert.deadline?.must_act_by ?? alert.must_act_by;
return [
`${emoji} *Signa alert* -- ${headline}${window}`,
`Watch: *${watchLabel}*`,
`Trademark: *${markLabel}*`,
mustActBy ? `Action by: *${mustActBy}*` : null,
]
.filter(Boolean)
.join('\n');
}
The alert.created body is self-contained: it carries the full alert object (event.summary, watch.name, trademark.mark_text, deadline, etc.) inline, so the message above renders human-readable names with no extra API call. The rich half is best-effort — on a rare hydration miss the body falls back to flat IDs only, which is why each field above uses alert.rich?.x ?? alert.flat_id. See the alert.created data fields for the full shape.
If you still want to enrich beyond what the body carries (e.g. fetch the full goods/services list), add a GET /v1/trademarks/{id} or GET /v1/watches/{id} call. Give the Worker its own Signa API key with the minimum scope (trademarks:read for trademark lookups, portfolios:manage for watch lookups), stored in a different secret than SIGNA_WEBHOOK_SECRET so the two can be rotated independently.
Set the env vars and deploy:
wrangler secret put SIGNA_WEBHOOK_SECRET
wrangler secret put SLACK_WEBHOOK_URL
wrangler deploy
Register the Worker URL with Signa:
import Signa from '@signa-so/sdk';
const signa = new Signa({ api_key: process.env.SIGNA_API_KEY });
const wh = await signa.webhooks.create({
url: 'https://signa-slack.<your-account>.workers.dev/',
description: 'Slack #legal-alerts',
enabled_events: ['alert.created'],
});
console.log('Set SIGNA_WEBHOOK_SECRET to:', wh.secret);
Copy wh.secret into the Worker as SIGNA_WEBHOOK_SECRET.
AWS Lambda + API Gateway alternative
import { verifyWebhookSignature } from '@signa-so/sdk';
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const body = event.body ?? '';
const ok = verifyWebhookSignature(
{
'webhook-id': event.headers['webhook-id'] ?? '',
'webhook-timestamp': event.headers['webhook-timestamp'] ?? '',
'webhook-signature': event.headers['webhook-signature'] ?? '',
},
body,
process.env.SIGNA_WEBHOOK_SECRET!,
);
if (!ok) return { statusCode: 401, body: 'invalid signature' };
const payload = JSON.parse(body);
if (payload.type === 'alert.created') {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: format(payload.data) }),
});
}
return { statusCode: 200, body: 'ok' };
};
Route an API Gateway POST to the Lambda. Set SIGNA_WEBHOOK_SECRET and SLACK_WEBHOOK_URL as Lambda env vars.
Tips
- Slack rate-limits incoming webhooks. For high-volume watches, batch with a 30-second buffer, or fan out to a topic and let multiple webhooks consume.
- Slack truncates messages over 40k chars. Keep formatting compact.