Rawly product interface
Founder & Sole Engineer Feb 2025 — Jan 2026 rawly.io

Rawly.

Four sales tools in one platform — email, LinkedIn and AI voice — running on infrastructure built to keep sending when things break.

Next.js 16 TypeScript Node.js PostgreSQL Redis · BullMQ Playwright OpenAI PM2

Outbound sales teams pay for four products at once: a lead database, an email sender, a LinkedIn automation tool, and increasingly an AI caller. Rawly does all four, and the interesting part isn’t the features — it’s that the work is long-running, rate-limited and failure-prone, so most of the engineering went into the machinery that keeps campaigns moving without getting accounts banned.

151KLines of TypeScript
91API routes
70Postgres tables
6Worker processes
01

The architecture decision

Rawly’s web tier is Next.js on serverless. Serverless cannot run a headless browser for ten minutes, and it cannot hold a LinkedIn session open. So the system is split in two: a modular monolith for the web tier, and a detached fleet of long-running Node processes for execution, with Redis and BullMQ as the only thing between them.

Not microservices — one codebase, one data model. The split exists for an operational reason, not an organisational one.

WEB TIER · VERCEL Next.js 16 · App Router 91 API routes, RSC dashboard 5 cron jobs (light, idempotent) COORDINATION Redis · BullMQ Per-tenant locks + heartbeat Backoff, dedupe, retention EXECUTION · PM2 ON VPS Worker fleet linkedin-worker · Playwright email-worker · Gmail API discovery-worker · scraping call-worker · Vapi + Twilio inbox-poll · every 10 min health-check · every 5 min daily-reset · midnight STATE PostgreSQL · RLS 70 tables, atomic RPCs enqueue drain
The web tier never blocks on slow work. Everything that can take minutes — a browser session, a mailbox send, a phone call — is a queued job with its own retry and failure story.
“Series B SaaS companies in Berlin, 50–500 people, with a VP of Sales” parse_search_request → { industry: "saas", stage: "series_b", city: "berlin", headcount: [50,500], role: "vp_sales" }
Discovery Companies matched
4 of 128 shown
Lattice
lattice.com · HR Tech · 240 staff
Enriched
Ramp
ramp.com · Fintech · 900 staff
Enriched
Linear
linear.app · Project Mgmt · 60 staff
Enriched
Notion
notion.so · Productivity · 700 staff
No pattern
Credits used
3
Verified emails
2 of 3
Skipped
1 — no pattern

02 — Discovery

Plain English in, a query out.

The model never answers the question. It fills in a schema — industry, stage, city, headcount, role — and the search runs as ordinary typed code against the company database.

Enrichment is cost-aware: a domain whose email pattern is already known at high confidence skips verification entirely, and free mailboxes are dropped as non-B2B before anyone pays for them.

  • LLM extracts parameters, it does not generate answers
  • Credits deducted in an atomic Postgres function, never in app code
  • Verification skipped above 80% pattern confidence to save spend
  • Bilingual intent routing — the same handler reads EN and TR

03 — The product

One surface for the whole motion.

Search, lists, contacts, companies, campaigns, inbox and analytics behind one login — and a chat bar that turns a sentence into a query instead of fifteen dropdowns.

app.rawly.io
Dashboard
Overview of your outreach
+ New Campaign
Find SaaS CTOs
Find SaaS CTOs in Berlin who raised Series A
04

The hard parts

Keeping one account out of two hands

A LinkedIn account can only have one browser session at a time. Two workers touching the same account gets it flagged. Every job therefore acquires a per-tenant Redis lock before it opens a browser, and extends that lock on a heartbeat while it works — so if a worker dies mid-job, the lock expires on its own instead of deadlocking the account.

Around that: stale-job recovery for anything stuck past ten minutes, duplicate-job prevention keyed on account plus target plus action, and a thirty-second drain on SIGTERM so a deploy never kills a job mid-flight.

// one atomic command — set only if absent, with a TTL await redis.set( `linkedin:lock:${userId}`, `${Date.now()}-${process.pid}`, "EX", 300, // 5 min TTL "NX" // fail if already held ); // extended every 60s while the job runs

Two-tier retry

Not all failures are the same. A dropped socket should retry in seconds; hitting a daily send limit should retry in hours. So there are two retry systems stacked: BullMQ’s exponential backoff for transient faults, and a database-scheduled retry ladder — 5 minutes, 30 minutes, 2 hours — driven by a scheduler that sweeps every five minutes.

Errors are classified before they’re retried at all: ECONNRESET and OUTSIDE_WORKING_HOURS are retryable; a permanently rejected action is thrown as an unrecoverable error so the queue stops wasting attempts on it.

// tier 1 — queue level attempts: 3, backoff: { type: "exponential", delay: 60000 } // tier 2 — DB level, long horizon RETRY_DELAYS_MS = [ 5 * 60 * 1000, // 5 min 30 * 60 * 1000, // 30 min 2 * 60 * 60 * 1000, // 2 hours ];

Deliverability as a control loop

Cold email dies when a mailbox’s reputation dies. Most tools let you set a daily limit by hand. Rawly scores each mailbox 0–100 from its own sending history — bounce rate, open rate, reply rate, error rate, recent activity, plus a live DNS check of SPF, DKIM and DMARC — and derives the daily quota from that score.

A healthy mailbox earns 100 sends a day. A failing one is throttled to 10 and flagged. On top of that, campaigns pause themselves when bounce rate crosses 5% or error rate crosses 10%, and no more than five messages a day reach any single recipient domain.

// weighted health score → daily quota bounce 25% open 20% reply 15% error 15% activity 10% SPF/DKIM/DMARC 15% // live DNS lookup 90–100 → 100/day <40 → 10/day + warning

A sequence engine with no hardcoded steps

A campaign is an ordered list of steps, and each step carries a channel, a delay and a set of conditions. The engine reads that data — it doesn’t know what “step 3” means. The same code runs a one-step campaign and a hundred-step campaign, and branches on real signals: connection accepted, email opened, link clicked, reply received.

When a step completes, the engine finds the next eligible one and dispatches it to the right channel’s queue. A reply stops the whole sequence for that recipient.

{ order: 1, channel: "email", delay_days: 0 } { order: 2, channel: "linkedin_connection", delay_days: 2 } { order: 3, channel: "linkedin_message", delay_days: 1, conditions: { linkedinConnected: true } } { order: 4, channel: "email", delay_days: 3 }

Browser automation that survives the real web

The LinkedIn worker runs Playwright against a site that actively resists automation. Sessions are serialised — cookies, localStorage, user agent — encrypted and stored, then restored on the next run so it isn’t logging in every time. Two-factor auth is handled end to end across email, SMS, authenticator and mobile approval, with the code passed asynchronously from the UI into the worker.

Exit IPs are geo-matched to the account’s real country through residential proxies, so a session doesn’t trip an “impossible travel” check. Behind it all: a provider interface and an explicit worker state machine, so a second source is an implementation rather than a rewrite.

The lesson that cost the most: the first version put 3–10 seconds between actions. Accounts got restricted. Version 3 moved to 60–120 seconds with working-hours and weekday limits, and conservative daily caps — 30 connections, 30 messages.

Measured, burned, fixed — not guessed at design time.

Sequence Sarah Chen · Head of Sales, Northwind
Step 3 of 5
Email — intro
day 0 · sent 09:12
Opened ×2
LinkedIn — connection request
day 2 · sent 11:40
Accepted
LinkedIn — message
day 3 · requires: connected ✓
Queued 14:22
Email — follow-up
day 6
Waiting
AI call — qualify
day 9
Waiting
Mailbox health
94 / 100
Next action
14:22
On reply
Stop sequence

05 — Sequence

Data in, no hardcoded steps.

A campaign is an ordered list of steps carrying a channel, a delay and a set of conditions. The engine reads it as data, finds the next step whose conditions hold, and hands it to that channel’s queue.

  • Same code runs 1 step or 100
  • Branches on real signals — accepted, opened, clicked
  • Each channel has its own queue and rate limits
  • A reply stops the sequence for that recipient

Nothing here is special-cased. Step 2 was accepted, so step 3’s condition held and it became eligible; had it been declined, the engine would have skipped straight to step 4. The same code runs a one-step campaign and a hundred-step one.

Deliverability Mailbox health → daily cap
auto
sarah@acme.io
SPF ✓  DKIM ✓  DMARC ✓  bounce 0.4%
94100/day
hello@acme.io
SPF ✓  DKIM ✓  DMARC ×  bounce 2.1%
7150/day
team@acme.io
SPF ✓  DKIM ×  DMARC ×  bounce 6.8%
3810/daycampaign auto-paused — bounce rate above 5% threshold
Score inputs
6
DNS weight
15%
Pause trigger
bounce > 5%

06 — Deliverability

The quota is earned, not set.

Every mailbox is scored 0–100 from its own sending history — bounce, open, reply and error rates, recent activity, plus a live DNS lookup for SPF, DKIM and DMARC. The score maps straight to how many messages it is allowed to send that day.

It is a feedback loop, not a setting. A mailbox that starts bouncing gets throttled within the hour; a campaign that crosses 5% bounce or 10% error pauses itself without anyone watching.

  • Six weighted inputs, DNS worth 15% of the score
  • 90+ sends 100/day · below 40 drops to 10/day and warns
  • Maximum 5 messages a day to any one recipient domain
  • 24-hour cooling period after five consecutive failures
Inbox Replies across both channels
3 new
Maya Foster — LaunchCrew
Re: Startup outreach on a budget · 12h ago
Sequence stopped
Jessica Adams — RevenueBase
Re: Sales ops tooling · 1d ago
Sequence stopped
Tyler Bennett — PulseBoard
LinkedIn reply · 2d ago
Sequence stopped
Daniel Reyes — Northwind
No reply · step 4 queued for tomorrow
Running
Polled every
5 min
Threads matched
by Gmail thread id
On reply
sequence halts

07 — Replies

A human answered. Stop everything.

Gmail is polled on a schedule and LinkedIn messaging on another, and each reply is matched back to the thread that produced it. The moment one lands, that recipient’s sequence is paused — no follow-up goes out after someone has already written back.

It sounds obvious. It is the single thing outbound tools get wrong most visibly, because the reply arrives on one channel and the next step is queued on another.

  • Reply detection runs on both email and LinkedIn
  • Matched by thread, not by guessing at the subject line
  • Pause is recorded with a reason, so the timeline stays readable
  • Bounces feed back into the sending mailbox’s health score
08

Correctness under concurrency

Six worker processes and a serverless web tier all touch the same rows. An application-level mutex can’t help across instances, so the race-sensitive operations live in the database as atomic functions: deducting credits, checking a search quota, incrementing a mailbox’s sent count, and claiming a row of work so two workers never grab the same one.

Tenant isolation is Postgres row-level security, with a strict split between the anon key used by the browser and the service-role key that never leaves the server.

09

Stack

Web

  • Next.js 16 · App Router
  • React 19 · Server Components
  • TypeScript
  • Tailwind v4 · Radix UI
  • 142-component design system
  • Recharts · cmdk

Backend & data

  • Node.js · TypeScript
  • PostgreSQL · Supabase
  • Row-level security
  • Atomic stored procedures
  • Redis · Upstash
  • BullMQ

Execution & ops

  • PM2 process supervision
  • Playwright
  • Residential proxies
  • Vercel cron · VPS cron
  • Worker heartbeats
  • Slack · Resend alerting

AI & integrations

  • OpenAI · function calling
  • Vapi · Twilio · ElevenLabs
  • Gmail API · OAuth2
  • Email verification providers
  • LemonSqueezy billing
10

Outcome

Rawly ran a closed beta with 50 customers. Built and operated by one engineer — product, infrastructure, billing and support.

The kind of infrastructure competitors staff with a team of twenty.