Skip to main content

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.