---
title: "Signal-Triggered Campaign Webhook for Buying Signals"
description: "Build a signal-triggered campaign webhook with the FirstSales CLI: verify, dedupe, suppress, add contacts, then a human starts it."
date: "2026-09-17"
tags: "webhook, buying-signals, cli, automation, cold-email, outbound"
readTime: "28 min read"
author: "FirstSales Team"
slug: "signal-triggered-campaign-webhook"
canonical: "https://firstsales.io/blog/signal-triggered-campaign-webhook/"
---

# Signal-triggered campaign webhook: start a campaign when a buying signal fires

**TL;DR:** The CLI does not expose Signals or webhooks yet, so you build a small receiver of your own. It verifies the sender, drops duplicates, checks suppression, adds the contact to a list with an idempotency key, and then a human or a daily batch starts the campaign after readiness and a test email.

---

## Table of contents

- [The honest starting point: what the CLI does not do](#the-honest-starting-point-what-the-cli-does-not-do)
- [The point of view: a signal should fill a list, not fire an email](#the-point-of-view-a-signal-should-fill-a-list-not-fire-an-email)
- [The architecture in one picture](#the-architecture-in-one-picture)
- [What counts as a signal worth acting on](#what-counts-as-a-signal-worth-acting-on)
- [Step 1: create a narrow key for the receiver](#step-1-create-a-narrow-key-for-the-receiver)
- [Step 2: verify the sender with an HMAC](#step-2-verify-the-sender-with-an-hmac)
- [Step 3: dedupe before you do anything](#step-3-dedupe-before-you-do-anything)
- [Step 4: check suppression and blocked domains](#step-4-check-suppression-and-blocked-domains)
- [Step 5: create the contact and add it to a list](#step-5-create-the-contact-and-add-it-to-a-list)
- [The whole receiver in one file](#the-whole-receiver-in-one-file)
- [Step 6: the human gate and the daily batch](#step-6-the-human-gate-and-the-daily-batch)
- [Starting the campaign without surprises](#starting-the-campaign-without-surprises)
- [Failure modes and what each one costs](#failure-modes-and-what-each-one-costs)
- [Test it with fake signals first](#test-it-with-fake-signals-first)
- [Comparing ways to react to a signal](#comparing-ways-to-react-to-a-signal)
- [FAQs](#faqs)
- [Conclusion](#conclusion)

## The honest starting point: what the CLI does not do

Let me start with the limit, because it shapes everything after it. The FirstSales CLI README says Signals and webhooks return `unsupported_operation` until public API support exists.

The README also tells you not to work around this by calling app-private routes. This post follows that rule.

So there is no `firstsales signals` command to subscribe to, and no outbound webhook from FirstSales that you can point at a URL. If a post online shows one, check it against the package README before you build on it.

What you do have is everything on the receiving side. The CLI can check suppression, create contacts, add them to lists, edit a campaign workflow, run readiness and start a campaign.

The signal itself comes from somewhere you already own. That may be your product, your billing system, a hiring tracker, a form, or a scraper you run.

That source posts to a small web service you write. The service then uses the CLI as its hands.

This is more work than a button, and it is also more honest. You control each step, and you can test each step without sending a single email.

If you want the signal ideas themselves, the existing posts on [buying signals for cold email](/blog/buying-signals-for-cold-email) and [signal-based cold email](/blog/signal-based-cold-email) cover what to watch. This post covers the plumbing that carries a signal safely into a campaign.

## The point of view: a signal should fill a list, not fire an email

Most signal automations are sold as a straight line. Something happens, and an email goes out within minutes.

That line is dangerous for three reasons. Signals repeat, signals lie, and the person behind a signal may already be on a do-not-contact list.

A webhook that sends mail directly turns each of those into a sent email you cannot take back. A webhook that only fills a list turns each of them into a row you can review.

So this post takes a firm position. The receiver never sends, never starts and never resumes anything.

It does the boring safe work: verify, dedupe, check, create, add. Then it stops and leaves a list for a person.

Speed matters less than most teams think. A reply to a signal that arrives the next morning, written by a human who read the context, usually beats an instant template.

If you have any doubt, read [email agent guardrails](/blog/email-agent-guardrails). The same logic applies: let machines prepare, and let a person approve what leaves the building.

There is one more reason, and it is about your sending domains. A burst of sends triggered by one noisy source can spike bounces and complaints in a single hour.

The post on [cold email domain burn rate](/blog/cold-email-domain-burn-rate) shows how fast that damage adds up. A list that waits for review is a brake on that failure.

## The architecture in one picture

Here is the whole flow. The diagram shows where the machine stops and a person starts.

```mermaid
graph TD
  S[Signal source: product, form, tracker] -->|HTTPS POST with HMAC header| R[Your receiver]
  R --> V{Signature valid}
  V -->|no| X1[Reject 401]
  V -->|yes| D{Seen this event id}
  D -->|yes| X2[Return 200, do nothing]
  D -->|no| P{suppression check}
  P -->|suppressed| X3[Log and drop]
  P -->|clear| C[contacts create with idempotency key]
  C --> L[contact-lists members add]
  L --> Q[Review queue or daily batch]
  Q --> H[Human: readiness, test email, start]
```

Read it from the top. The first three diamonds are cheap checks that need no account access at all, apart from the suppression call.

Everything after `contacts create` writes data, so those steps use an idempotency key. If the same event arrives twice, the second write is safe.

The last box is the human gate. Nothing in the receiver crosses it.

## What counts as a signal worth acting on

Before any code, decide what the receiver accepts. A receiver that accepts anything becomes a spam cannon the day someone misconfigures a source.

Write a short allow list of event types. Each type needs a reason, an owner and a daily cap.

Good candidates are events that show a real, recent, relevant action. A trial user who invited three teammates is one. A visitor who asked for pricing is another.

Weak candidates are broad events like "visited the site". The post on [compound buying signals](/blog/compound-buying-signals) explains why one weak signal rarely justifies outreach, while two or three together do.

Your own product usage is a rich source. See product usage outbound triggers for ways to turn usage into triggers without being creepy about it.

Hiring is another. The posts on [hiring signal outbound](/blog/hiring-signal-outbound) and [job posts vs hiring signals](/blog/job-posts-vs-hiring-signals) cover how to read them without overreading.

### Negative signals matter as much

A signal list needs a stop list. Someone who just complained, unsubscribed, or replied "not interested" should never enter through a signal.

The post on [negative signals in a lead list](/blog/negative-signals-lead-list) covers what to filter out. Your receiver should check suppression for exactly this reason, and Step 4 does.

### Set a daily cap before launch

Pick a cap on how many contacts the receiver adds per day, per source. A cap turns a runaway source into a bounded problem.

A good starting cap is low, such as 25 per day per source. Raise it after a few weeks of clean results.

When the cap is hit, the receiver should still log the event and return success. It just does not add the contact, and it flags the overflow for a person.

## Step 1: create a narrow key for the receiver

The receiver runs on a server that faces the internet. That makes its credential more exposed than any key on your laptop.

Create a key just for it, with the least access that works. Name it after the job.

```bash
firstsales api-keys create \
  --org org_123 \
  --data '{"name":"signal-receiver-prod","scopes":["contacts:write"],"accessLevel":"workspace"}' \
  --json
```

The scope list here is a starting point based on the README example, which shows `contacts:write` and `campaigns:read`. Your receiver also needs to read suppression and write list membership, so confirm the exact scope names in the Developer API authentication docs and grant only those.

The raw key is shown once. Put it straight into your host's secret store and do not paste it into a chat, a ticket or a prompt.

Do not add `--idempotency-key` to this command. The README states that `api-keys create` does not support it, because raw keys are reveal-once.

Two safety points apply here. A workspace-level key stays inside one workspace, which limits damage if the key leaks.

And what the key cannot do matters more than what it can. Leave out campaign start, delete and billing scopes.

Read [API keys for outbound agents](/blog/api-keys-for-outbound-agents) for a fuller scope matrix. The principle is old and short: give each process the least privilege it needs to do its job.

### What if the key leaks

Plan the response before you need it. Revoke the key and the receiver stops writing at once.

```bash
firstsales api-keys revoke --org org_123 --key key_123 --confirm --json
```

The README lists `api-keys revoke` as a destructive command, which is why it needs `--confirm`.

Because revoked keys fail immediately, the damage window is the time between the leak and your revoke. A narrow key makes that window harmless.

## Step 2: verify the sender with an HMAC

Your endpoint is a public URL. Anyone who finds it can post to it.

You need proof that a request came from your signal source. The standard answer is an HMAC signature, defined in [RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104).

The idea is simple. You and the sender share a secret, and the sender computes a hash of the request body using that secret and sends the hash in a header.

Your receiver recomputes the hash from the body it received. If the two match, the body is authentic and unchanged.

Here is the check in Node, using only the built-in `crypto` module.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySignature(rawBody, headerValue, secret) {
  if (!headerValue) return false;
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const given = Buffer.from(headerValue, 'utf8');
  const want = Buffer.from(expected, 'utf8');
  return given.length === want.length && timingSafeEqual(given, want);
}
```

Three details matter here. Use the raw bytes of the body, not a parsed and re-serialized version, because re-serializing changes the bytes.

Compare with `timingSafeEqual`, not `===`. A plain comparison can leak how many leading characters matched.

And check the length first, because `timingSafeEqual` throws on buffers of different sizes.

### Your source decides the header name

The header name and the exact signing format depend on your signal source. A product you built yourself can use any name, such as `X-Signature`.

If a third-party tool sends the signal, read its own documentation for the header and the format. This post does not assume any particular vendor's format.

### Add a timestamp to stop replays

A valid signature on an old request is still valid. An attacker who captured one request could send it again later.

Include a timestamp in the signed content and reject requests older than five minutes. If your source cannot sign a timestamp, the dedupe step in Step 3 covers most of the same risk.

### Keep the secret out of the code

Store the shared secret in the same secret store as the API key. Rotate it on a schedule, and rotate it at once if anyone who knew it leaves.

Never log the secret, and never log the full signature header.

## Step 3: dedupe before you do anything

Signals repeat. Sources retry on timeouts, queues deliver at least once, and people click twice.

If the receiver treats each delivery as new, one signal becomes two contacts or two campaigns. Dedupe at the door.

Ask your source to include a unique event id in every payload. Store each id you have processed, and skip any id you have seen.

```js
import Database from 'better-sqlite3';
const db = new Database('seen.db');
db.exec('CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, at TEXT)');

const mark = db.prepare('INSERT OR IGNORE INTO seen (id, at) VALUES (?, ?)');

export function firstTime(eventId) {
  const res = mark.run(eventId, new Date().toISOString());
  return res.changes === 1;
}

// Call this when processing fails, so the source's retry is not skipped as a duplicate.
const unmark = db.prepare('DELETE FROM seen WHERE id = ?');
export function forget(eventId) {
  unmark.run(eventId);
}
```

`INSERT OR IGNORE` against a primary key makes the check atomic. Two simultaneous deliveries cannot both win.

If you run more than one receiver instance, use a shared store instead of a local file. A small database table or a key-value store works.

### Return success for duplicates

When you see a duplicate, return a 200 status and do nothing. A non-200 response tells most sources to retry, which makes the duplicate problem worse.

Log the duplicate so you can count how often it happens. A high rate points to a flaky source.

### Two layers of idempotency

Dedupe at the door protects your receiver. The idempotency key in Step 5 protects the FirstSales write.

You want both. If your seen-table is lost in a crash, the idempotency key still prevents a double create.

The CLI README describes `--idempotency-key` as a flag for retryable mutations. Build the key from the event id so a retry produces the same key.

## Step 4: check suppression and blocked domains

Before you create a contact, find out whether you are allowed to write to this person. This is where a signal turns from interesting to safe.

The CLI offers `suppression check`, which takes up to 100 values. It checks your suppression list so you do not add someone who unsubscribed or bounced.

```bash
firstsales suppression check person@example.com --org org_123 --workspace ws_123 --json
```

Run the command against a real address only in your own account, and read the output with `--pretty` first. This post does not name the response fields, because the live output is the authority.

Your receiver should treat any "suppressed" result as a hard stop. Log the event with the reason, drop it, and return success to the source.

### Blocked domains

Some domains you never want to contact, such as your own company, your customers who asked to be left alone, or domains that act as spam traps. The CLI has `blocked-domains` with list, add and remove.

The README notes that a blocked domain also blocks its subdomains, and that `blocked-domains add` requires `--idempotency-key`. Add your own domain and key partners on day one.

For a deeper walk through do-not-contact handling, read [do-not-contact list with MCP](/blog/do-not-contact-list-mcp). The same lists apply whether you reach them by MCP or by CLI.

### Verify the address

A signal can carry a mistyped or fake address. Sending to it bounces, and bounces hurt your sender reputation.

If your source collects addresses from a form, verify them before you add the contact. The post on [email verification before sending](/blog/email-verification-before-sending) explains the options.

Google's [sender guidelines](https://support.google.com/a/answer/81126) tell senders to keep spam rates under 0.10% and never reach 0.30%. A list full of bad addresses pushes you toward those limits.

### Fail closed

If the suppression check itself fails, because of a timeout, an auth error or a rate limit, do not add the contact. Fail closed.

Put the event in a retry queue and try again later. A delayed contact costs you minutes, while a wrongly added contact can cost you a complaint.

## Step 5: create the contact and add it to a list

Now the receiver has a verified, new, clear event. It can write.

Create the contact first. The README example shows the shape of the call.

```bash
firstsales contacts create \
  --org org_123 --workspace ws_123 \
  --data '{"email":"person@example.com","firstName":"Sam"}' \
  --idempotency-key "signal-evt_9f3a-contact" \
  --json
```

The `--idempotency-key` is built from the event id, so a retry of the same event produces the same key. A second create with that key does not make a second contact.

### Add the contact to a list

Next, add the contact to a list that you keep for this signal. The command is `contact-lists members`, which the README notes is a POST, with placeholders mapping to flags.

```bash
firstsales contact-lists members \
  --org org_123 --workspace ws_123 \
  --list list_123 \
  --data '{"contactIds":["contact_123"]}' \
  --idempotency-key "signal-evt_9f3a-member" \
  --json
```

The `--list` flag follows the README's rule that path placeholders become flags. The body shape above is an illustration, so run the command with `--dry-run` and check the real usage output before you rely on it.

The README describes `--dry-run` as showing the method, URL, redacted headers and body without sending anything. It needs no auth and makes no network call.

```bash
firstsales contact-lists members --list list_123 \
  --data '{"contactIds":["contact_123"]}' --dry-run
```

### Keep one list per signal type

Use a separate list for each signal type. That keeps the context clear, since everyone on the "pricing page request" list arrived for the same reason.

It also makes caps and reviews easy. You can see at a glance how many contacts each source added today.

Name lists with the date or the week if you want a clean audit trail, for example `signal-pricing-2026-w41`. A fresh list per week also gives a human a bounded batch to review.

### Adding contacts is not starting a campaign

Do not assume a running campaign will pick up new list members by itself. This post does not claim that, and the material it draws on does not confirm it.

Treat the list as a staging area. A person decides what happens to it in Step 6.

For importing larger batches by hand, see [import leads from the terminal](/blog/import-leads-from-terminal).

## The whole receiver in one file

Here is a minimal receiver that ties Steps 2 to 5 together. It uses Node's built-in HTTP server and runs the CLI with `execFile`, which avoids a shell and so avoids shell injection from event data.

```js
import http from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { verifySignature } from './verify.js';
import { firstTime, forget } from './seen.js';

const run = promisify(execFile);
const { SIGNING_SECRET, ORG_ID, WORKSPACE_ID, LIST_ID, DAILY_CAP = '25' } = process.env;
const ALLOWED = new Set(['pricing_request', 'trial_invited_team']);
let addedToday = 0; // reset by a daily timer, not shown

const base = ['--org', ORG_ID, '--workspace', WORKSPACE_ID, '--json'];

function fs(args) {
  return run('firstsales', [...args, ...base], { timeout: 20000 });
}

const server = http.createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/hooks/signal') {
    res.writeHead(404).end(); return;
  }
  const chunks = [];
  for await (const c of req) chunks.push(c);
  const raw = Buffer.concat(chunks);

  if (!verifySignature(raw, req.headers['x-signature'], SIGNING_SECRET)) {
    res.writeHead(401).end(); return;
  }

  let evt;
  try { evt = JSON.parse(raw.toString('utf8')); }
  catch { res.writeHead(400).end(); return; }

  const { id, type, email, firstName } = evt;
  if (!id || !ALLOWED.has(type) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email || '')) {
    res.writeHead(400).end(); return;
  }

  if (!firstTime(id)) { res.writeHead(200).end('duplicate'); return; }
  if (addedToday >= Number(DAILY_CAP)) { console.log('cap reached', id); res.writeHead(200).end('capped'); return; }

  try {
    const check = await fs(['suppression', 'check', email]);
    if (JSON.stringify(JSON.parse(check.stdout)).includes('"suppressed":true')) { console.log('suppressed', id); res.writeHead(200).end('suppressed'); return; }

    const created = await fs(['contacts', 'create',
      '--data', JSON.stringify({ email, firstName }),
      '--idempotency-key', `signal-${id}-contact`]);
    const contactId = JSON.parse(created.stdout).id; // confirm the real field with --pretty first

    await fs(['contact-lists', 'members', '--list', LIST_ID,
      '--data', JSON.stringify({ contactIds: [contactId] }),
      '--idempotency-key', `signal-${id}-member`]);

    addedToday += 1;
    res.writeHead(200).end('queued');
  } catch (err) {
    console.error('failed', id, err.code, err.message);
    forget(id); // let the source's retry through; idempotency keys stop duplicate writes
    res.writeHead(500).end();
  }
});

server.listen(8787);
```

This is a sketch to adapt, not a production service. Three lines in it carry assumptions you must check against real output.

The `suppressed` field check, the `.id` field on the created contact, and the body shape for list members all depend on live responses. Run each command once with `--pretty` and fix the code to match.

### Why `execFile` and not a shell string

Event data comes from outside. If you built a shell command with string concatenation, a crafted name could inject commands.

`execFile` passes arguments as an array with no shell involved. The data stays data.

### Why the receiver has no start command

Look through the file. There is no `campaigns start`, no `pause`, no `resume` and no email send.

The key it runs with could not do those things anyway. Both the code and the credential enforce the same boundary.

### Handling exit codes

The CLI returns documented exit codes: 3 for auth, 4 for not found and 5 for rate limiting. `execFile` rejects on a non-zero exit and exposes the code on the error.

Map them on purpose. Exit 3 means stop and alert, since the key is bad. Exit 5 means back off and retry. Exit 4 usually means a wrong list id in your configuration.

## Step 6: the human gate and the daily batch

You now have a list that fills itself. Next comes the part that makes this safe: a person decides.

There are two patterns. Pick one per signal type.

### Pattern A: review each contact

For high-value, low-volume signals, notify a person per event. Post to a team channel with the signal type and the contact, and let the person decide.

This suits events like a demo request or a pricing page visit from a named account. Volume is low, so a human can read each one.

### Pattern B: a daily batch

For higher-volume signals, run a daily job that summarizes the list. It reports how many contacts arrived, from which signal types, and any that were capped or suppressed.

A person reads the summary each morning and approves the batch. The approval is the gate.

The weekly version of this reporting habit is covered in [weekly campaign reports with cron and the CLI](/blog/weekly-campaign-report-cron-cli). The daily batch summary uses the same snapshot idea on a shorter clock.

### What the person checks

Make the review a short fixed checklist, so it is quick and consistent.

1. Do the contacts match the audience this campaign is for?
2. Is the signal still fresh, or did it sit in the queue for days?
3. Do any addresses look wrong or role-based, such as `info@`?
4. Is the daily total sensible compared with normal volume?
5. Does the message still make sense for this signal?

If any answer is no, the person removes those contacts from the list or holds the whole batch.

### Deleting is permanent

If someone wants a contact gone, remember that `contacts delete` and other delete commands cannot be undone. Prefer removing the contact from the list over deleting the record, unless you truly need it gone.

## Starting the campaign without surprises

When the person approves a batch, the campaign step begins. This step stays manual, or at most assisted, and it follows a fixed order.

First, attach the reviewed list to the campaign workflow. The CLI has `campaigns workflow update` with a repeatable `--include-list` flag.

```bash
firstsales campaigns workflow update \
  --org org_123 --workspace ws_123 \
  --campaign camp_123 \
  --include-list list_123 \
  --dry-run
```

Run with `--dry-run` first and read the preview. Remove it only when the preview shows exactly the change you intend.

### Readiness, then a test email, then start

Never start straight from a list. Run the readiness check, and then send a test email to yourself.

```bash
firstsales campaigns readiness --org org_123 --workspace ws_123 --campaign camp_123 --pretty
firstsales campaigns send-test-email --org org_123 --workspace ws_123 --campaign camp_123 --pretty
```

The README does not document the bodies and extra flags these two commands may need, so check `--help` for each before you run them. Read the test email in your inbox as the recipient would.

The posts on [the campaign readiness check from the CLI](/blog/campaign-readiness-check-cli) and [sending a test email with Claude Code](/blog/send-test-email-claude-code) walk through both steps in detail.

### Starting

Only after readiness and the test email look right, start the campaign. The `campaigns start` command needs a JSON body with `savedVersionId`, `readinessVersion`, `idempotencyKey` and `confirmation`.

Preview it with `--dry-run` first. The full safe sequence, including pausing and resuming, is in [start, pause and resume campaigns from the CLI](/blog/start-pause-campaigns-cli).

### Where an agent can help

An AI agent can assist with the review summary, the readiness read and the dry-run preview. It should not hold a key that can start campaigns without a person.

The MCP route is good for editing and watching. Read [CLI, MCP or the app](/blog/cli-vs-mcp-vs-app-outbound) for how to split these jobs, and note that campaign creation is done with the CLI command `firstsales campaigns create`, not through MCP.

### Think about the message too

A signal gives you timing and context, and copy decides whether the email works. A message that names the trigger honestly, without sounding like surveillance, does best.

Never write "I saw you visited our pricing page three times". Write something that is useful for someone in that situation, and let the timing do the work quietly.

## Failure modes and what each one costs

Every automation fails in specific ways. This table lists the ones that matter here and the control that answers each.

| Failure | What happens | Control | Cost if it fails |
|---|---|---|---|
| Forged request | Fake contact added | HMAC check, timestamp | Junk in your list |
| Duplicate delivery | Same person added twice | Event id table, idempotency key | Double contact |
| Suppressed person added | They get an email they asked not to get | `suppression check`, fail closed | Complaint, legal risk |
| Source goes noisy | Hundreds of contacts in an hour | Daily cap per source | Bounce spike |
| Key leaks | Attacker writes contacts | Narrow key, quick revoke | Junk, not sends |
| Receiver crashes mid-write | Contact without list membership | Separate idempotency keys per step | Orphan contact |
| Prompt injection in event text | Model acts on hostile text | No model in the receiver | None, by design |
| Stale signal | Email lands a week late | Freshness check at review | Weak reply rate |

The seventh row deserves a note. Because the receiver is plain code with no model in it, text in a signal payload cannot give it instructions.

If you later add an AI step to draft messages, keep it behind the human gate. The [MCP security for outbound](/blog/mcp-security-outbound) post explains why untrusted text and tool access should never meet without a person in between.

### Monitoring the receiver

A silent receiver is a broken receiver. Log every outcome: accepted, duplicate, suppressed, capped, rejected and failed.

Count those outcomes each day. A sudden jump in rejected requests means someone is probing the endpoint, and a drop to zero accepted events means the source or the signature broke.

For wider monitoring ideas, see monitoring outbound agents.

## Test it with fake signals first

Never test with real prospects. Build a test harness that sends fake, signed events to your receiver.

Use addresses you own, such as plus-addressed variants of your own inbox. That way a mistake emails you, not a stranger.

```bash
BODY='{"id":"evt_test_001","type":"pricing_request","email":"you+test1@yourdomain.com","firstName":"Test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | awk '{print $2}')
curl -i -X POST http://localhost:8787/hooks/signal \
  -H "Content-Type: application/json" -H "X-Signature: $SIG" -d "$BODY"
```

Run the same command twice. The second response should say `duplicate`, and no second contact should exist.

### The test list

Run these cases once before you point a real source at the receiver.

1. A valid event: expect 200 and one new contact in the list.
2. The same event again: expect 200 and no new contact.
3. A bad signature: expect 401.
4. A missing signature: expect 401.
5. An unknown event type: expect 400.
6. A malformed email: expect 400.
7. A suppressed address (add one to your own suppression list first): expect 200 and no contact.
8. The cap reached: set `DAILY_CAP=1`, send two events, and expect the second to be capped.
9. The CLI failing: revoke the test key and expect a 500 plus an alert.

### Use `--dry-run` for the write steps

While you build, point the receiver's CLI calls at `--dry-run` through a config switch. The README says dry-run needs no auth and makes no network call, so you can test the whole flow with no account access at all.

Switch it off only for the final test with your own addresses. Do not run any of this against a real production account until the dry-run version behaves.

### Rehearse the human gate too

Do a full rehearsal of Step 6 with the test list. Run readiness, send the test email to yourself, preview the start with `--dry-run`, and stop there.

That rehearsal proves the people part works, which is the part automation tests cannot cover.

## Comparing ways to react to a signal

This table compares the usual approaches. A tick means the approach does that thing.

| Approach | Verifies the source | Dedupes | Checks suppression | Human before send | Safe to leave unattended |
|---|---|---|---|---|---|
| Signal fires an email directly | ✗ | ✗ | ✗ | ✗ | ✗ |
| Signal adds to a running campaign blindly | ✗ | ✗ | ✗ | ✗ | ✗ |
| Spreadsheet export, manual import each week | n/a | ✓ | ✓ | ✓ | ✓ |
| Receiver fills a list, daily human batch (this post) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Receiver fills a list, human reviews each contact | ✓ | ✓ | ✓ | ✓ | ✓ |

The spreadsheet approach is slow but safe. It loses signal freshness, which is often the whole point of acting on a signal.

The receiver pattern keeps the safety of the spreadsheet and recovers most of the speed. The human gate is a small delay, not a blocker.

### When not to build this

If you get fewer than a handful of signals a week, skip the receiver. Import a list by hand and save the maintenance.

Build it when signals arrive daily, when freshness matters, or when several sources feed the same campaigns. At that point, the receiver pays for itself within weeks.

### Where the Copilot fits

FirstSales also has Copilot, reachable from the CLI with `firstsales copilot ask`. It can help you reason about a campaign, but it is not a webhook endpoint and does not replace the receiver.

For a comparison of Copilot and Claude Code over MCP, see Copilot vs Claude Code with MCP. For the surrounding terminal workflow, see [running outbound from the terminal](/blog/outbound-from-the-terminal).

## FAQs

### Can the FirstSales CLI subscribe to signals or webhooks?

No. The README states that Signals and webhooks return `unsupported_operation` until public API support exists. You build your own receiver and use the CLI for the actions it does support.

### What is a signal-triggered campaign?

It is outreach that begins because a specific event happened, such as a pricing request or a trial invite. In this design the event fills a list, and a person starts the campaign.

### Why not send an email directly from the webhook?

Signals repeat, signals can be false, and the person may be on a suppression list. A direct send turns each of those into a sent email you cannot recall.

### What does the receiver do?

It verifies the HMAC signature, drops duplicate event ids, checks suppression, creates the contact with an idempotency key, and adds it to a list. It never starts, pauses or sends anything.

### How do I verify that a request is genuine?

Compute an HMAC of the raw request body with a shared secret and compare it to the signature header using a constant-time comparison. RFC 2104 defines HMAC.

### Why use the raw body for the HMAC?

Parsing and re-serializing JSON can change the bytes, which changes the hash. Sign and verify the exact bytes that were sent.

### How do I stop duplicates?

Require a unique event id in each payload, store processed ids in a table with a primary key, and return 200 for repeats. Also pass an idempotency key derived from the event id to each write.

### What should the idempotency key look like?

Build it from the event id and the step, such as `signal-evt_9f3a-contact` and `signal-evt_9f3a-member`. A retry of the same event then produces the same keys.

### Which API key scopes does the receiver need?

Only the scopes for creating contacts, checking suppression and writing list membership, in one workspace. Look up exact names in the authentication docs and leave out campaign start, delete and billing.

### Can I put `--idempotency-key` on `api-keys create`?

No. The README says it is unsupported there because raw keys are reveal-once.

### How does the receiver check suppression?

It calls `firstsales suppression check` with the address, which accepts up to 100 values. A suppressed result is a hard stop, and a failed check also stops the flow.

### What if the suppression check times out?

Fail closed. Queue the event for a retry and do not add the contact until the check succeeds.

### Do running campaigns pick up new list members automatically?

Do not assume so. This post treats the list as a staging area and attaches it to the campaign through a reviewed `campaigns workflow update --include-list` step.

### What must happen before `campaigns start`?

Run `campaigns readiness`, then `campaigns send-test-email`, and read the test email. Preview the start with `--dry-run` before you run it for real.

### Can MCP create the campaign?

No. Create it with `firstsales campaigns create`, then use MCP to edit and watch it. MCP campaign creation is not confirmed.

### How many contacts should the receiver add per day?

Start low, such as 25 per day per source, and raise the cap after weeks of clean results. A cap turns a runaway source into a bounded problem.

### What if a source starts sending thousands of events?

The cap stops the writes, the receiver logs the overflow, and a person looks at the source. Revoke the key if you suspect abuse.

### Should I use an AI model inside the receiver?

No. Keeping the receiver as plain code means hostile text in a payload cannot act as instructions. Put any model behind the human gate.

### How do I test without emailing real people?

Send signed fake events from a script, use addresses you own, and run the write steps with `--dry-run`. Never test with real prospects.

### What happens if someone gets deleted by mistake?

`contacts delete` and other delete commands cannot be undone. Prefer removing a contact from a list, and keep delete behind a human with a different key.

## Conclusion

A buying signal is a reason to look at someone, and it is a poor reason to send an email on its own. The sound design is a small receiver that prepares a list and a person who decides.

Here are the takeaways.

- The CLI does not expose Signals or webhooks yet, so build your own receiver and use the CLI for contacts, lists, suppression and campaigns.
- Verify every request with an HMAC, dedupe by event id, and check suppression before any write, failing closed on errors.
- Use idempotency keys built from the event id on every write, and a daily cap per source.
- Give the receiver a narrow key with no start, delete or billing scope, and never paste that key into a prompt or a post.
- Keep a person between the list and the send. Run `campaigns readiness` and `campaigns send-test-email` before `campaigns start`.
- Remember that delete commands cannot be undone, and that campaign creation is a CLI action, not an MCP one.

Begin with one signal type, one list and a cap of 25. After two clean weeks, add a second source.

For the key design behind this setup, read [API keys for outbound agents](/blog/api-keys-for-outbound-agents). For the start sequence, read [start, pause and resume campaigns from the CLI](/blog/start-pause-campaigns-cli). To run outbound on a platform built for careful automation, try [FirstSales](https://firstsales.io).