Skip to content
NewSee how
buying signals

Buying-Intent Alert in Slack With a Human Approval

Build a buying-intent alert: a signed webhook receiver, a Slack message with context and a human approve step before FirstSales acts.

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.

DecisionOptionsOur default
Who gets the alertOne channel, owner by account, round robinOne channel with an owner field
What counts as a signalAny event, or events above a thresholdAbove a threshold you define
How long an alert stays openHours, a day, until acted onA window you set and write down
What approve doesCreate contact only, or also enrollCreate contact, then enroll after review
Who can approveAnyone, or named peopleNamed 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.

js
import 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.

js
const 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.

js
async 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.

js
async 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.

PartExampleWhy it matters
WhoAlex at Example CoRecognition
WhyVisited pricing page twice this weekThe reason to act
FreshnessReceived 2 minutes agoWhether it is still live
OwnerNamed person or teamAvoids two people acting
ActionRun the approve command with this idOne 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.

js
async 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.

bash
firstsales 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.

bash
firstsales 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.

bash
body='{"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

CheckPassFail
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 readiness and campaigns send-test-email before campaigns 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.

Keep reading

Outreach that keeps going after you log off.Leads, emails, follow-ups and replies, handled.

Connect unlimited mailboxes and let AI agents find leads, write each email and follow up. Or send your own templates in manual mode. From $29/mo.

$1 for 3 days · Cancel anytime · No contract

Install with AI

Your coding agent sets up the FirstSales CLI and skills for you.

Prompt for Claude Code

I'm setting up FirstSales (https://firstsales.io), an AI outbound sales platform: campaigns, contacts, a reply inbox, deliverability and lead signals. Help me get my workspace sending real, good emails end to end, from Claude Code.

Read these first so you know how FirstSales works:
- Developer docs and API: https://developer.firstsales.io
- CLI: https://www.npmjs.com/package/@firstsales.io/cli
- Skills for coding agents: https://github.com/firstsalesio/firstsales-skills (read README.md and GUIDE.md)

Setup
1. Install the CLI: npm install -g @firstsales.io/cli
2. Install the FirstSales skills:
   claude plugin marketplace add firstsalesio/firstsales-skills
   claude plugin install firstsales@firstsales
3. Ask me if I already have a FirstSales account. If not, send me to https://app.firstsales.io to sign up.
4. Ask me to create an API key under Settings → API → API Keys, with only the scopes we need.
   Never ask me to paste the key into this chat. Tell me to run this in my own terminal, so the key stays hidden:
   read -rs FS_KEY && firstsales auth login --api-key "$FS_KEY"; unset FS_KEY
5. Run `firstsales whoami` and tell me which workspace and organisation we are connected to.

Then ask me about my business: what I sell, who I sell to (roles, company size, countries), my best customers, and what I use today for outbound (tools, lists, mailboxes). Walk me through:
1. Teaching FirstSales my business: website, offer, proof points and case studies in the knowledge base, so the AI writes like someone who knows the product.
2. Connecting my sending mailboxes and checking my sending domain: SPF, DKIM, DMARC and warm-up. If anything is unhealthy, tell me the exact DNS record to add.
3. Bringing in contacts: import my CSV or CRM export into a clean list, remove duplicates and bad emails, and block domains I must never email (customers, competitors, my own).
4. Building my first campaign: the right list, senders, steps and timing. Write the sequence with me, show me the drafts, and do not launch until I say yes.
5. Setting up Signals if it fits: find people engaging with LinkedIn posts about topics my buyers care about, and turn them into leads.
6. Inviting my team and giving each person the right access.

Once campaigns run, show me how to work the reply inbox from here: which replies need an answer, draft responses in my voice, and move good conversations into deals. If I use Cal.com, connect it so follow-ups can offer my booking link (not the first email).

Throughout, as you learn about my business, proactively suggest and help create:
- Reply templates for the questions and objections my prospects are likely to raise, based on my product, site and codebase if you can see them.
- Campaign ideas and segments worth testing, with the reason for each.

Safety rules:
- Show every change as a dry run (--dry-run) first and wait for my yes. Never send emails, launch campaigns or delete anything without my yes.
- Tell me before anything that spends credits, and how many.

Be opinionated. If the docs give a sensible default, just use it. Don't make me choose between five options.