TL;DR: Build a small webhook receiver that verifies a signed signal, checks suppression, posts a Slack alert with context and waits for a person to approve. Only then does a firstsales command create the contact. The CLI does not handle webhooks itself, so the receiver lives on your side.
#The point of view: an alert should end in a human decision
Most intent alerts fail in one of two ways. They fire so often that nobody reads them, or they fire and then trigger an email with nobody looking.
Our position is in the middle. The alert should reach a person fast, carry enough context to decide in ten seconds and end in one explicit action: approve or skip.
That is the unique angle of this build. We are not building an auto-start pipeline.
We are building a short path from a signal to a human decision, with a log of what was decided.
If you want the version where each signal adds a lead to a campaign list automatically, and a person reviews the batch before starting the campaign, read starting a campaign when a buying signal fires. The two posts solve different problems, and you may use both for different signals.
#What the CLI can and cannot do here
Read this section before writing a line of code. It saves you from building on a route that does not exist.
The FirstSales CLI README says Signals and Webhooks return unsupported_operation until public API support exists. It also says not to work around that by calling app-private routes.
So the receiver cannot be a FirstSales feature in this build. It is a small service you run, which receives events from whatever produces your signals, and then uses supported CLI commands to act.
#What we use from the CLI
We use suppression check, contacts create, contact-lists commands, campaigns readiness and campaigns send-test-email. Each is listed in the CLI command groups.
We also keep the safety lines: run campaigns readiness and campaigns send-test-email before campaigns start, never paste an API key into a prompt or a post, and remember that delete commands cannot be undone.
#What we do not claim
We do not claim the app has a native signal feed you can subscribe to through the CLI. We do not claim MCP can create campaigns.
If you connect the product MCP server, the live tools/list is the authority on what your grant allows.
Treat every field name for a FirstSales request body as something to confirm in the public contract or with firstsales commands --json. This post shows documented shapes where the README gives them and says so where it does not.
#The design in one picture
The path has five stops and two gates. A signal arrives, the receiver checks it, a person decides and then the CLI acts.
graph TD A[Signal source posts event] --> B[Receiver verifies signature] B -->|Invalid| X[Reject with 401] B -->|Valid| C[Dedupe by event id] C -->|Seen| Y[Ignore] C -->|New| D[Suppression check via CLI] D -->|Suppressed| Z[Log and stop] D -->|Clear| E[Save pending item] E --> F[Post Slack alert] F --> G{Person decides} G -->|Skip| H[Mark skipped] G -->|Approve| I[Create contact with idempotency key] I --> J[Add to list for a ready campaign] J --> K[Person starts campaign after readiness and test email]
Notice there is no arrow from the alert straight to a send. The campaign start sits behind the readiness check and a person.
#Why two gates
The first gate is automatic: signature, dedupe and suppression. It keeps junk and forbidden contacts out of the channel.
The second gate is human. It decides whether this signal, on this account, today, is worth acting on.
#Why Slack
Teams already look at chat. Slack documents incoming webhooks, which accept a JSON payload posted to a URL, so it is the simplest way to reach a person.
Any chat tool with a similar inbound webhook works the same way. We use Slack because the pattern is well documented.
#Before you build: decisions to make
Write these down first. Each one shapes the code.
| Decision | Options | Our default |
|---|---|---|
| Who gets the alert | One channel, owner by account, round robin | One channel with an owner field |
| What counts as a signal | Any event, or events above a threshold | Above a threshold you define |
| How long an alert stays open | Hours, a day, until acted on | A window you set and write down |
| What approve does | Create contact only, or also enroll | Create contact, then enroll after review |
| Who can approve | Anyone, or named people | Named people |
#Pick signals you can explain
Buying signals are reasons to look, not reasons to send. Our guides on buying signals for cold email, compound buying signals and intent-based prospecting versus static lists explain how to choose and combine them.
A strong alert says why this account is interesting now. A weak one only says that something happened.
#Decide the speed you need
Speed matters for some signals and not for others. Speed to lead in outbound covers where fast response pays and where it does not.
If a signal is valuable for weeks, you do not need a real-time pipeline. A daily digest may serve you better, and it costs less to maintain.
#Why buyers make timing matter
Buyers research on their own before they talk to anyone. Gartner's March 2026 release on a survey of 646 buyers found 45% used generative AI during a recent purchase, mostly to gather information on vendors and products (Gartner press release).
That means a lot of buying activity happens before a seller is involved. A signal is your window into it, and a short path from signal to person is how you use it well.
#Step 1: define the signal payload
Your signal source decides the shape of the event. We define the smallest shape the receiver needs and ask the source, or a small adapter, to produce it.
#The minimum fields
json{ "eventId": "evt_8f3a", "occurredAt": "2026-10-08T09:30:00+05:30", "email": "[email protected]", "firstName": "Alex", "company": "Example Co", "domain": "example.com", "reason": "Visited pricing page twice this week", "source": "site-analytics" }
This is an illustration of a shape, not a FirstSales schema. Every field has a job: eventId for dedupe, email and domain for suppression, reason for the human and source for the audit trail.
#Treat the payload as untrusted
The payload comes from outside. Fields like reason can hold any text, including text written to manipulate a model or a reader.
OWASP lists prompt injection as the first item in its Top 10 for LLM applications, including indirect injection through external content. The receiver in this post does not call a model, but if you later add a model to summarize the reason, remember this.
#Keep it small
Reject payloads over a size limit and ignore fields you do not use. A smaller surface is easier to reason about.
The receiver below caps the body at 64 KB and reads only the fields in the table above.
#Step 2: verify the signature
Anyone who finds your receiver URL can post to it. Without verification, a stranger can fill your channel with fake alerts or push fake contacts into your approve queue.
#A shared-secret signature
A common scheme is an HMAC of the raw request body using a shared secret, sent in a header. The receiver recomputes the HMAC and compares.
Your signal source defines its own header name and scheme, so check its documentation. The example uses a header called x-signature with a hex SHA-256 HMAC, and you should adapt it.
#The receiver, part one
This is a complete small receiver for Node.js 18 or later with no dependencies. Save it as receiver.mjs.
jsimport http from 'node:http'; import crypto from 'node:crypto'; import fs from 'node:fs'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const run = promisify(execFile); const SECRET = process.env.SIGNAL_SHARED_SECRET; const SLACK_URL = process.env.SLACK_WEBHOOK_URL; const ORG = process.env.FS_ORG; const WS = process.env.FS_WORKSPACE; const MAX_BODY = 64 * 1024; if (!SECRET || !SLACK_URL || !ORG || !WS) { console.error('Missing SIGNAL_SHARED_SECRET, SLACK_WEBHOOK_URL, FS_ORG or FS_WORKSPACE'); process.exit(1); } function validSignature(raw, header) { if (!header) return false; const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(String(header)); return a.length === b.length && crypto.timingSafeEqual(a, b); } const server = http.createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/signal') { res.writeHead(404).end(); return; } const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > MAX_BODY) { req.destroy(); return; } chunks.push(c); }); req.on('end', async () => { const raw = Buffer.concat(chunks); if (!validSignature(raw, req.headers['x-signature'])) { res.writeHead(401).end(); return; } res.writeHead(202).end(); try { await handle(JSON.parse(raw.toString('utf8'))); } catch (err) { console.error('handle failed:', err.message); } }); }); server.listen(8787, '127.0.0.1');
The server binds to 127.0.0.1. Put it behind a reverse proxy with TLS rather than exposing it directly.
#Why timingSafeEqual
A plain string comparison can leak how many leading characters matched. The constant-time comparison avoids that, and it costs one line.
Check the lengths first, because timingSafeEqual throws on buffers of different sizes.
#Respond fast
The receiver answers with 202 before it does any work. Signal sources usually retry slow endpoints, and a retry creates a duplicate event.
That is why the next step dedupes by eventId.
#Step 3: dedupe and check suppression
Two checks keep bad alerts out of the channel: a repeat check and a suppression check.
#Dedupe
Keep a small file of event ids you have seen. A file is enough for a low-volume receiver.
jsconst SEEN_FILE = 'seen.json'; const seen = new Set(fs.existsSync(SEEN_FILE) ? JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')) : []); function remember(id) { seen.add(id); fs.writeFileSync(SEEN_FILE, JSON.stringify([...seen])); }
If your volume grows, replace the file with a database. For a first build, the file is easier to inspect.
#Suppression check through the CLI
The README documents suppression check for up to 100 addresses or domains, returning value, suppressed and reason for each.
jsasync function isSuppressed(email, domain) { const { stdout } = await run('firstsales', [ 'suppression', 'check', email, domain, '--org', ORG, '--workspace', WS, '--json', ]); const result = JSON.parse(stdout); return JSON.stringify(result).includes('"suppressed":true'); }
Run the command by hand on your own workspace and look at the real output before you rely on this parse. The string match is a simple guard, and you should replace it with a precise field check once you have seen the shape.
#Fail closed
If the check errors, the receiver should not post the alert as if the contact were clear. In the handler below, an error stops the flow and logs the reason.
A guard that passes on error is a report. A guard that stops on error is a guard.
#The receiver, part two
This is the handler. Add it to the same file.
jsasync function handle(evt) { const required = ['eventId', 'email', 'domain', 'reason']; for (const k of required) { if (typeof evt[k] !== 'string' || !evt[k]) throw new Error(`missing ${k}`); } if (seen.has(evt.eventId)) return; remember(evt.eventId); if (await isSuppressed(evt.email, evt.domain)) { console.log('suppressed, not alerting:', evt.eventId); return; } const item = { id: evt.eventId, email: evt.email, firstName: evt.firstName || '', company: evt.company || '', reason: clean(evt.reason), receivedAt: new Date().toISOString(), status: 'pending', }; const pending = fs.existsSync('pending.json') ? JSON.parse(fs.readFileSync('pending.json', 'utf8')) : []; pending.push(item); fs.writeFileSync('pending.json', JSON.stringify(pending, null, 2)); await notify(item); } function clean(text) { return String(text).replace(/[\u0000-\u001f]/g, ' ').slice(0, 300); }
The handler checks required fields, ignores repeats, stops on suppression and stores a pending item before it notifies. If Slack is down, the item is still in pending.json.
#Step 4: post a useful Slack alert
The alert is the product. A noisy or vague alert trains people to ignore the channel.
#What the message needs
A person should be able to decide in about ten seconds. That needs five things: who, why, how fresh, who owns it and what to do.
| Part | Example | Why it matters |
|---|---|---|
| Who | Alex at Example Co | Recognition |
| Why | Visited pricing page twice this week | The reason to act |
| Freshness | Received 2 minutes ago | Whether it is still live |
| Owner | Named person or team | Avoids two people acting |
| Action | Run the approve command with this id | One clear next step |
#The notify function
Slack incoming webhooks accept a JSON body with a text field, per the Slack documentation. Add this to the receiver.
jsasync function notify(item) { const text = [ `Buying signal: ${item.firstName || 'Unknown'} at ${item.company || 'unknown company'}`, `Why: ${item.reason}`, `Received: ${item.receivedAt}`, `To approve: ./approve.sh ${item.id}`, `To skip: ./skip.sh ${item.id}`, ].join('\n'); const res = await fetch(SLACK_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }), }); if (!res.ok) throw new Error(`slack returned ${res.status}`); }
The Slack webhook URL is a secret, because anyone with it can post to your channel. Keep it in an environment variable and out of logs and git.
#Plain text first
Slack supports richer layouts and interactive buttons, but buttons need an app that can receive callbacks. That is a bigger build, so this post keeps a plain message and a command.
You can add buttons later. The decision logic stays the same.
#Control the volume
Add a daily cap per source and a minimum bar for the reason. If the channel gets more alerts than people can read, raise the bar.
An alert nobody reads has no value, and the fix is usually fewer, better alerts. The outbound lead scoring model can help you set the bar.
#Step 5: the approve step
Approval is where the human decision becomes an action. Keep it explicit, logged and safe to repeat.
#The approve script
Save this as approve.sh and make it executable.
bash#!/usr/bin/env bash set -euo pipefail id="${1:?usage: ./approve.sh <event-id>}" item=$(jq -c --arg id "$id" '.[] | select(.id==$id and .status=="pending")' pending.json) if [ -z "$item" ]; then echo "No pending item with id $id" >&2 exit 1 fi payload=$(echo "$item" | jq -c '{email: .email, firstName: .firstName}') dry_flag="" [ "${DRY_RUN:-0}" = "1" ] && dry_flag="--dry-run" firstsales contacts create \ --org "$FS_ORG" --workspace "$FS_WORKSPACE" \ --data "$payload" \ --idempotency-key "intent-$id" $dry_flag \ --json if [ -n "$dry_flag" ]; then echo "Dry run only. $id stays pending." exit 0 fi tmp=$(mktemp) jq --arg id "$id" 'map(if .id==$id then .status="approved" else . end)' pending.json > "$tmp" mv "$tmp" pending.json echo "Approved $id"
The command mirrors the documented first mutation in the README: contacts create with a JSON body and an idempotency key. The key is built from the event id, so approving twice cannot create two contacts.
#Preview first
Run the first few approvals as DRY_RUN=1 ./approve.sh <event-id>. The script adds --dry-run, prints the method, URL and body, and exits before it marks the item approved, so the item stays pending.
After a few clean dry runs, drop DRY_RUN=1. Use it again after any change you make to the script.
#The skip script
Skipping is as important as approving, because it keeps the pending list honest.
bash#!/usr/bin/env bash set -euo pipefail id="${1:?usage: ./skip.sh <event-id>}" tmp=$(mktemp) jq --arg id "$id" 'map(if .id==$id then .status="skipped" else . end)' pending.json > "$tmp" mv "$tmp" pending.json echo "Skipped $id"
Review the skipped items weekly. If you skip the same kind of signal every time, remove it at the source.
#Who may approve
Run the approve script only from a machine or account that named people control. The script uses your CLI credentials, so whoever can run it can create contacts.
Create a key for this job with firstsales api-keys create, using the least access that covers contacts create and the list step. API keys for outbound agents explains how to scope it.
#Step 6: from approved contact to campaign
An approved contact is not a sent email. This step connects the two without skipping the checks.
#Add the contact to a list
Use the contact-lists commands to organize approved contacts. Check the registry for the exact members flags before scripting them, because this post does not assume them.
bashfirstsales contact-lists list --org org_123 --workspace ws_123 --json firstsales commands --json
The second command prints the registry, which is the source for CLI docs and the right place to confirm flags.
#Use a campaign that is already ready
Do not create a campaign on the fly for each signal. Prepare one campaign for each type of signal, with copy that fits the reason, and keep it ready.
Create it with firstsales campaigns create, then review it with the steps in building a cold email campaign from Claude Code. Use the readiness check from the pre-launch checklist.
#The launch rule
Run campaigns readiness and campaigns send-test-email before campaigns start, every time.
bashfirstsales campaigns readiness --org org_123 --workspace ws_123 --campaign camp_123 --json firstsales campaigns send-test-email --org org_123 --workspace ws_123 --campaign camp_123 --data-file test-email.json --json
The campaigns start body needs a savedVersionId, a readinessVersion, an idempotencyKey and a confirmation. A person supplies the confirmation.
The guide on sending a test email first covers the test step.
#Copy that fits the signal
A signal-based email should not read like a signal-based email. Referencing "I saw you visited our pricing page" can feel like surveillance.
Our post on the creepiness line in personalization and signal-based cold email explain how to use a signal without saying it out loud.
#Testing the whole path
Test each part alone, then test the whole chain. Do this before any real signal arrives.
#Test the signature
Send a request with a wrong signature and confirm a 401. Send one with a correct signature and confirm a 202.
bashbody='{"eventId":"evt_test1","email":"[email protected]","domain":"example.com","reason":"Test event"}' sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SIGNAL_SHARED_SECRET" -hex | awk '{print $NF}') curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8787/signal \ -H "x-signature: $sig" -H 'content-type: application/json' -d "$body"
A 202 means the signature passed. A 401 on a valid signature means your secret or encoding differs from the sender.
#Test the guards
Send the same eventId twice and confirm only one alert appears. Use an address you know is suppressed in a test workspace and confirm no alert appears.
Break the CLI on purpose, for example by unsetting the key, and confirm the receiver logs a failure and posts nothing.
#Acceptance table
| Check | Pass | Fail |
|---|---|---|
| Wrong signature returns 401 | ✓ | ✗ |
| Valid event returns 202 and posts one alert | ✓ | ✗ |
| Repeated event id posts nothing | ✓ | ✗ |
| Suppressed address posts nothing | ✓ | ✗ |
| CLI failure stops the flow and logs | ✓ | ✗ |
| Approving twice creates one contact | ✓ | ✗ |
| No path sends an email without a person | ✓ | ✗ |
#Test with a human
Have a teammate who has not seen the code read ten sample alerts and say what they would do. If they hesitate, the message lacks context.
Change the message until the decision is obvious, then test again. This is the cheapest improvement in the build.
#Expire old items
A signal loses value as it ages. Add a small script that marks pending items older than your window as expired, so nobody approves a stale one.
bash#!/usr/bin/env bash set -euo pipefail hours="${1:?usage: ./expire.sh <max-age-hours>}" tmp=$(mktemp) jq --argjson h "$hours" 'map(if .status=="pending" and ((now - (.receivedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601)) > ($h * 3600)) then .status="expired" else . end)' pending.json > "$tmp" mv "$tmp" pending.json
Run it from a scheduler every hour. Pick the window with your team, write it in the README and show the age in every alert.
#A daily digest as the fallback
For signals that stay useful for days, replace live alerts with one digest message each morning. The same receiver stores items, and a scheduled job posts a summary of what is pending.
A digest is calmer, cheaper to run and easier to act on in one sitting. Use live alerts only where minutes matter.
#Running it safely
A receiver is a public-facing service, so treat it like one.
#Deployment basics
Run it as a service under a process manager, behind TLS, with logs that do not contain secrets. Restrict who can reach the host and who can read the environment.
Stop what you start. If you run it locally for testing, track the process and stop it when you finish, so you do not leave a listener open on a port.
#Secrets
You have three: the signal secret, the Slack webhook URL and the FirstSales key. Keep all three in environment variables or a secrets store.
Never paste any of them into a prompt, a chat or a post. If one leaks, rotate it and review what it could reach.
Create FirstSales keys with firstsales api-keys create and revoke old ones with --confirm.
#Mail rules still apply
Even a human-approved email must follow the rules. The FTC's CAN-SPAM compliance guide covers US requirements, including opt-out handling, and the suppression check helps you honor them.
Google's email sender guidelines set expectations for authentication and complaint rates for bulk senders. See the 2026 legality guide and the spam complaint rate threshold for more.
#Logs for audit
Keep pending.json and the receiver log. They show what arrived, what was decided and when.
If a contact complains, you can show exactly why they were contacted and who approved it.
#What can go wrong
Most failures come from a few predictable places.
Retries create duplicates. The source retries and you post two alerts. Dedupe by event id.
The channel floods. A noisy source fires hundreds of events. Add a cap and raise the bar.
Stale alerts. Someone approves a three-day-old signal. Show the age in the message and expire old items.
Two people approve. Both act. Name an owner in the message and record who approved.
A guard that fails open. The suppression call errors and the alert posts anyway. Fail closed.
Prompt text in the payload. A field carries instructions aimed at a model. Treat it as data and do not feed it to an assistant with send permission.
#Watch it like any other system
Add a weekly check of counts: signals received, alerts posted, approved, skipped and expired. If approvals fall to zero, the signal is not working or the message is not clear.
Our post on the daily outbound standup in one prompt shows one way to read these numbers each morning, and weekly campaign reports with cron shows how to schedule it.
#Deliverability follows the list
Signal-based sends go to fewer, warmer contacts, which tends to help, but it does not remove the need for healthy sending. Check email-auth status, warmup status and tracking-domains verify before you rely on any campaign.
The deliverability checklist covers the rest.
#When to automate more, and when not to
Once the path is stable, you will want to remove steps. Be selective.
#Good candidates to automate
Automate the checks: signature, dedupe, suppression and expiry. Automate the logging and the weekly counts.
Automate the creation of the contact for signals that have proven reliable and low risk, if your team agrees and you keep the other guards.
#Keep a person for these
Keep a person on the decision to start a campaign. Keep a person on high-value accounts, on any signal from a new source and on any message that names the signal.
If you do move toward more automation, read starting a campaign when a buying signal fires and guardrails for email-sending agents first, and compare it with human in the loop cold email.
#Where this sits in the stack
A receiver and an alert are one layer of a larger setup. The five layers of an AI outbound agent stack shows how signals, research, writing and sending fit together.
For the wider terminal workflow, see running outbound from the terminal, and for where each interface fits, CLI, MCP or the app.
#FAQ
#Can FirstSales receive webhooks through the CLI?
No. The CLI README says Signals and Webhooks return unsupported_operation until public API support exists.
Build the receiver on your side and use supported commands to act.
#Why add a human approval step?
A signal is a reason to look, not a reason to send. A person judges whether this account and this moment are worth acting on, and the log records who decided.
#What does the receiver need to run?
Node.js 18 or later, the firstsales CLI, jq for the scripts and three secrets in environment variables: the signal secret, the Slack webhook URL and a FirstSales key.
#How does signature verification work?
The sender computes an HMAC of the raw body with a shared secret and sends it in a header. The receiver recomputes it and compares in constant time.
Check your source's documentation for its own header and scheme.
#Why respond with 202 before doing the work?
Senders often retry slow endpoints, which creates duplicates. Answering fast and processing after reduces retries, and dedupe by event id handles the rest.
#How do I dedupe events?
Keep the event ids you have seen and ignore repeats. A small file works at low volume, and a database works as volume grows.
#How do I check suppression?
Run firstsales suppression check with the email and domain. It returns value, suppressed and reason for each, and takes up to 100 values per call.
#What if the suppression check fails?
Stop and log it. A check that passes on error lets a send reach someone who opted out.
#What should a good Slack alert contain?
Who, why, how fresh, who owns it and what to do next. If a teammate cannot decide from it in about ten seconds, add context.
#Can I use Slack buttons instead of commands?
Yes, but buttons need an app that can receive callbacks, which is a larger build. The commands in this post keep the same decision logic with less setup.
#How do I stop duplicate contacts on approval?
Use --idempotency-key built from the event id. Approving twice then creates one contact.
#Is contacts create the only step?
It is the first. Adding the contact to a list and enrolling it in a ready campaign comes next, and the campaign start needs a readiness check, a test email and a person.
#How do I start the campaign?
Run campaigns readiness and campaigns send-test-email first. The campaigns start body needs a savedVersionId, a readinessVersion, an idempotencyKey and a confirmation.
#Can MCP start a campaign for me?
Do not assume so. The product MCP catalog is per tenant and per grant, and we did not confirm campaign creation through it.
Check the live tools/list.
#Where should the receiver run?
Under a process manager behind TLS, bound to localhost with a reverse proxy in front. Restrict access to the host and to its environment variables.
#How do I keep secrets safe?
Use environment variables or a secrets store, keep them out of logs and git, and never paste them into a prompt or a post. Rotate any secret that leaks.
#How do I prevent alert fatigue?
Cap alerts per source, raise the bar for what counts and show the reason in plain words. Review skipped items weekly and remove signals you always skip.
#What if a payload contains instructions aimed at a model?
Treat every payload field as data. OWASP lists prompt injection as a leading LLM risk, so keep any assistant that reads payloads away from send and delete permissions.
#Does a signal guarantee a reply?
No. A signal improves your odds of reaching someone at a useful time, but it does not make them want your offer.
Measure replies and meetings, and adjust.
#How is this different from the list-filling version?
This build ends every single alert in a human decision. The approach in starting a campaign when a buying signal fires adds leads to a list automatically, and a person reviews the batch and starts the campaign.
#Conclusion
A buying-intent alert is only worth building if it ends in a decision. A signed receiver, a clean Slack message and an explicit approve step give you that without risking a send nobody looked at.
The short version:
- The CLI does not handle webhooks, so run a small receiver yourself.
- Verify the signature, dedupe by event id and fail closed on suppression.
- Write alerts a person can act on in ten seconds.
- Make approval explicit, logged and safe to repeat with idempotency keys.
- Run
campaigns readinessandcampaigns send-test-emailbeforecampaigns start. - Track counts weekly and cut signals you always skip.
To begin, write the signal payload you want, run firstsales whoami --json and test the receiver with a fake signed event. Next, read starting a campaign when a buying signal fires to compare the automatic version.
Never paste an API key into a prompt or a post, and remember that delete commands cannot be undone.