> ## Documentation Index
> Fetch the complete documentation index at: https://docs.seltz.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Monitor a company list for M&A deals using structured outputs

> How to track a list of companies for M&A activity with one monitor, a webhook, and structured Answer verification.

## Overview

When tracking M\&A activity across a large list of companies, such as a Fortune 25-style watchlist, the naive approach is to poll a search per company, or stand up a separate feed per company. Neither scales: polling means paying for requests whether or not anything changed, and one feed per company means managing dozens of schedules and webhook endpoints for what is really a single workflow.

A Seltz [monitor](/monitor/concepts) batches many searches under one schedule and tells you about new results with a [webhook](/monitor/guides/webhooks), so you get one feed and one notification channel for the whole list instead of one per company.

This tutorial walks through that pattern: one monitor with a search request per company, a webhook to avoid polling, and [Answer](/answer/guides/basic-answer) with a `response_format` schema to verify and extract deal details from each new result rather than parsing the raw record yourself.

## Outline

1. Create one monitor with a search request per company, and attach a webhook.
2. Verify and handle a webhook delivery.
3. Turn each new result into structured deal information with a single Answer call.
4. Alert someone when a deal is detected.

The examples in this tutorial track a handful of large companies standing in for a full watchlist, but the same pattern applies to any list of entities and any kind of event you can phrase as a search.

## 1. Create one monitor with a search request per company, and attach a webhook.

A monitor's `search_requests` is a list. One call to `create` can hold a search per company, up to [1,000 requests per monitor](/monitor/reference#limits). Attach a `webhook` in the same call, so you're notified when a run finishes rather than polling for new records.

<Note>
  A monitor's name is unique per organization among live monitors: creating a second monitor with the same name returns `409 ALREADY_EXISTS`. The webhook secret is returned once, in the create response, and never again, so store it immediately.
</Note>

<CodeGroup>
  ```python Python theme={null}
  from seltz import Seltz

  client = Seltz(api_key="your-api-key")

  # A representative sample of a Fortune 25-style watchlist. Extend with your
  # full list, up to the 1,000-request limit per monitor.
  WATCHLIST = [
      "Walmart",
      "Amazon",
      "Apple",
      "Berkshire Hathaway",
      "UnitedHealth Group",
  ]

  response = client.monitor.create(
      "watchlist-ma-deals",
      cadence="6h",
      search_requests=[f"{company} acquisition merger" for company in WATCHLIST],
      webhook={
          "url": "https://example.com/seltz/monitors",
          "events": ["run.completed"],
      },
  )

  monitor = response.monitor
  print(monitor.monitor_id)

  # Returned once, at create, and never again.
  webhook_secret = response.webhook_secret
  ```

  ```typescript TypeScript theme={null}
  import { Seltz } from "seltz";

  const client = new Seltz({ apiKey: "your-api-key" });

  // A representative sample of a Fortune 25-style watchlist. Extend with your
  // full list, up to the 1,000-request limit per monitor.
  const watchlist = [
    "Walmart",
    "Amazon",
    "Apple",
    "Berkshire Hathaway",
    "UnitedHealth Group",
  ];

  const response = await client.monitor.create({
    name: "watchlist-ma-deals",
    cadence: "6h",
    searchRequests: watchlist.map((company) => `${company} acquisition merger`),
    webhook: {
      url: "https://example.com/seltz/monitors",
      events: ["run.completed"],
    },
  });

  const monitor = response.monitor;
  console.log(monitor!.monitorId);

  // Returned once, at create, and never again.
  const webhookSecret = response.webhookSecret;
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.seltz.ai/v1/monitors \
    -H "x-api-key: $SELTZ_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "watchlist-ma-deals",
      "cadence": "6h",
      "search_requests": [
        {"query": "Walmart acquisition merger"},
        {"query": "Amazon acquisition merger"}
      ],
      "webhook": {
        "url": "https://example.com/seltz/monitors",
        "events": ["run.completed"]
      }
    }'
  ```
</CodeGroup>

`cadence` is a judgment call: deal news is not minute-sensitive, so a longer cadence like `6h` keeps cost down without meaningfully delaying an alert. See [Concepts](/monitor/concepts#cadence) for the full grammar and the one-hour floor.

## 2. Verify and handle a webhook delivery.

Deliveries are signed following the [Standard Webhooks](https://www.standardwebhooks.com/) specification. Verify with the Standard Webhooks library for your language rather than write custom signature-checking code. This is the same verification shown in [Webhooks](/monitor/guides/webhooks).

<CodeGroup>
  ```python Python theme={null}
  from standardwebhooks import Webhook, WebhookVerificationError


  def handle_delivery(monitor_id: str, secret: str, headers: dict[str, str], body: bytes):
      try:
          payload = Webhook(secret).verify(body, headers)
      except WebhookVerificationError:
          return

      if payload["event"] != "run.completed":
          return

      run_id = payload["run"]["run_id"]
      page = client.monitor.list_run_records(monitor_id, run_id, limit=100)
      for record in page.records:
          # Step 3 turns each new record into structured deal information.
          process_record(record.search_result.document)
  ```

  ```typescript TypeScript theme={null}
  import { Webhook, WebhookVerificationError } from "standardwebhooks";

  async function handleDelivery(
    monitorId: string,
    secret: string,
    headers: Record<string, string>,
    body: Buffer,
  ) {
    let payload: any;
    try {
      payload = new Webhook(secret).verify(body.toString("utf8"), headers);
    } catch (e) {
      if (e instanceof WebhookVerificationError) return;
      throw e;
    }

    if (payload.event !== "run.completed") return;

    const runId = payload.run.run_id;
    const page = await client.monitor.listRunRecords(monitorId, runId, { limit: 100 });
    for (const record of page.records) {
      if (record.payload.case !== "searchResult") continue;
      const document = record.payload.value.document;
      if (!document) continue;
      // Step 3 turns each new record into structured deal information.
      await processRecord(document);
    }
  }
  ```
</CodeGroup>

<Note>
  Both samples take the **raw** request body, which is the bytes as received before any JSON parsing. A framework that returns an already-parsed object has discarded the raw bytes, so use the raw-body accessor your framework provides. This is the body of whatever route handler receives the webhook `POST` in your framework of choice, not a complete server.
</Note>

## 3. Turn each new result into structured deal information with a single Answer call.

Rather than parsing the record's Markdown content yourself to decide whether it's a real deal, use [`answer`](/api-reference/answer#body-response_format) with a `response_format` JSON schema. The only thing pulled from the record itself is its headline, used to anchor a fresh, targeted question. The verification and extraction are entirely Answer's job.

<CodeGroup>
  ```python Python theme={null}
  import json

  DEAL_FORMAT = {
      "type": "json_schema",
      "json_schema": {
          "name": "deal_info",
          "schema": {
              "type": "object",
              "properties": {
                  "is_deal": {"type": "boolean"},
                  "acquirer": {"type": ["string", "null"]},
                  "target": {"type": ["string", "null"]},
                  "status": {"type": ["string", "null"]},
              },
              "required": ["is_deal", "acquirer", "target", "status"],
              "additionalProperties": False,
          },
          "strict": True,
      },
  }

  def process_record(document) -> None:
      headline = document.content.splitlines()[0].lstrip("# ").strip()

      response = client.answer(
          f"{headline}. Is this a confirmed M&A deal? If so, name the acquirer, "
          "the target, and the deal status (announced, completed, or terminated).",
          response_format=DEAL_FORMAT,
      )
      try:
          deal = json.loads(response.answer)
      except json.JSONDecodeError:
          return

      if deal["is_deal"]:
          alert_deal(deal, document.url)  # Step 4
  ```

  ```typescript TypeScript theme={null}
  const dealFormat = {
    type: "json_schema",
    json_schema: {
      name: "deal_info",
      schema: {
        type: "object",
        properties: {
          is_deal: { type: "boolean" },
          acquirer: { type: ["string", "null"] },
          target: { type: ["string", "null"] },
          status: { type: ["string", "null"] },
        },
        required: ["is_deal", "acquirer", "target", "status"],
        additionalProperties: false,
      },
      strict: true,
    },
  };

  async function processRecord(document: { content: string; url: string }) {
    const headline = document.content.split("\n")[0].replace(/^#+\s*/, "").trim();

    const result = await client.answer({
      query:
        `${headline}. Is this a confirmed M&A deal? If so, name the acquirer, ` +
        "the target, and the deal status (announced, completed, or terminated).",
      responseFormat: dealFormat,
    });

    let deal;
    try {
      deal = JSON.parse(result.answer);
    } catch {
      return;
    }

    if (deal.is_deal) {
      await alertDeal(deal, document.url); // Step 4
    }
  }
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.seltz.ai/v1/answer \
    -H "x-api-key: $SELTZ_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "query": "Apple to acquire Northwind Robotics for $2B. Is this a confirmed M&A deal? If so, name the acquirer, the target, and the deal status (announced, completed, or terminated).",
      "response_format": {
        "type": "json_schema",
        "json_schema": {
          "name": "deal_info",
          "strict": true,
          "schema": {
            "type": "object",
            "properties": {
              "is_deal": {"type": "boolean"},
              "acquirer": {"type": ["string", "null"]},
              "target": {"type": ["string", "null"]},
              "status": {"type": ["string", "null"]}
            },
            "required": ["is_deal", "acquirer", "target", "status"],
            "additionalProperties": false
          }
        }
      }
    }'
  ```
</CodeGroup>

Not every new result is a real deal: an article that merely mentions a watchlist company is expected, so expect the value of `is_deal` to often be `false`. Both outcomes below are responses from the same schema, used to structure responses from the the watchlist in step 1. The first result is a confirmed deal, and the second is a routine article that just happened to match the search.

<CodeGroup>
  ```json is_deal: true theme={null}
  {
    "is_deal": true,
    "acquirer": "Walmart",
    "target": "Vizio",
    "status": "announced"
  }
  ```

  ```json is_deal: false theme={null}
  {
    "is_deal": false,
    "acquirer": null,
    "target": null,
    "status": null
  }
  ```
</CodeGroup>

<Note>
  Truncated generation can leave `response.answer` unparsable as JSON. The `try`/`except` block above skips the record. A malformed `response_format` schema is different: it's rejected before the request is billed, but that's a one-time schema-authoring mistake that raises on every call, not a per-record condition. Write and test `DEAL_FORMAT` once rather than expecting the loop above to handle it.
</Note>

## 4. Alert someone when a deal is detected.

`alert_deal` stands in for however you notify a person: for example, Slack, email, PagerDuty, or your own on-call tooling. Swap in your own integration; the important part is passing along the structured fields Answer already extracted, not the raw record.

<CodeGroup>
  ```python Python theme={null}
  def alert_deal(deal: dict, source_url: str) -> None:
      message = (
          f"Possible deal detected: {deal['acquirer']} / {deal['target']} "
          f"({deal['status']})\nSource: {source_url}"
      )
      # Replace with your own notification channel.
      print(message)
  ```

  ```typescript TypeScript theme={null}
  async function alertDeal(deal: { acquirer: string; target: string; status: string }, sourceUrl: string) {
    const message =
      `Possible deal detected: ${deal.acquirer} / ${deal.target} ` +
      `(${deal.status})\nSource: ${sourceUrl}`;
    // Replace with your own notification channel.
    console.log(message);
  }
  ```
</CodeGroup>

## Next steps

* [Monitor Concepts](/monitor/concepts) — Monitors, runs, records and webhooks
* [Webhooks](/monitor/guides/webhooks) — Attaching, disabling and removing a webhook
* [Monitor Reference](/monitor/reference) — Endpoints, limits and errors
* [Answer API Reference](/api-reference/answer#body-response_format) — Full `response_format` specification
