Why isn't my LinkedIn campaign sending?
If your LinkedIn campaign isn't sending — or your dashboard is showing numbers that don't match your LinkedIn inbox — there are four common patterns we know about, and as of May 1, 2026 every one of them has shipped fixes. This page walks through each pattern, why it happens, and what to do.
- Why am I seeing 0 invites sent today even though the campaign is running?
Symptom: your LinkedIn → Accounts page shows 'invites sent today: 80/80' (or similar capped number), every new send attempt errors with 'daily limit reached', but your `campaign_send_jobs.completed_at` ground-truth shows only 7-12 actual sends today.
Why (LL#273, May 1, 2026): the daily counter that gates rate limits had drifted upward — historical inflation (e.g. a corrupted-counter incident, a stuck-job-not-cleaned-up backlog, or a clock-skew double-decrement) locked in an inflated value. The reconciler that's supposed to heal drift was using the wrong formula: `Math.max(existing_counter, actual, inflight)` — the `existing_counter` term meant any historical inflation could only ever go UP, never down. Net: the counter said 142, the reality was 12, the rate-limit gate blocked all new sends.
Fix: the reconciler now uses `Math.max(actual_today, inflight_today)` — drops the cached value from the max and AUTHORITATIVELY recomputes from event rows + inflight. The live writers (campaign-engine + linkedin-accounts INCREMENT call sites) still guarantee the counter is monotonically increasing within a single day; the reconciler now is a pure HEAL operation, not a write-shadow. A one-shot heal script (`server/scripts/heal-inflated-linkedin-counter-2026-05-01.ts`) recomputed all active accounts on May 1: Anisha 142→12, Pinki 87→9, Hafiz 73→7 (51 accounts total, 41 healed downward).
What to do: nothing. The heal already ran. If you still see the symptom, hard-refresh your dashboard (60s cache) and check the campaign-card per-step counter — that's always ground-truth from `campaign_events`. If those still disagree by more than 24h, contact support at hello@warmysender.com.
- Why does my dashboard show '0% accept' when I have replies?
Symptom: Talha's EU SaaS Outreach campaign showed 'Acceptance rate = 18%' with 47 listed accepted prospects — but the LinkedIn inbox + the campaign-events log showed only 31 actual accepts. The 16-row gap was confusing.
Why (LL#275, May 1, 2026): the 16 'phantom' rows were the LL#266 / LL#270 cohort — rows where `linkedin_accepted_at` was zeroed by the May-1 reverter because the original accept signal came from a cross-campaign signal leak (the same prospect was enrolled in 2+ campaigns by the same workspace; the bridge resolver applied the accept across all matching enrollments, even campaigns that never sent the invite). The legacy `linkedin_events` table still had the original `invite_accepted` event row (we deliberately don't delete events for forensic purposes), so the stats refresher's `GREATEST(events_count, prospects_count)` formula picked up the forensic event and double-counted.
Fix: the stats refresher's events CTE now excludes events whose paired `campaign_prospects.linkedin_accepted_at IS NULL AND linkedin_status='pending_accept'` — a clear positive signal that the accept was reverted. The prospects CTE is unchanged. Backfill ran May 1: Talha's 47→31, marc@replaiy.ai's 89→79, Selva PIPL 1,208→1,058. Accept rate corrections show within 1 hour of the next refresh tick.
What to do: hard-refresh your dashboard. Cross-check ground-truth against your LinkedIn inbox (always correct) and the campaign-card per-step counter (always correct). If the gap persists past 1 hour, contact support.
- Why does my follow-up message never fire after acceptance?
Symptom: Selva's PIPL Connection campaign — step 1 send_invite went out fine, the prospect accepted (visible in LinkedIn + the campaign event log), but step 2 send_message never fired. The `campaign_send_jobs` table showed 28 stuck send_message jobs cycling through `failed → recreated → failed` on every reconciler tick.
Why (LL#277, May 1, 2026): step 2's message template had `{{company}` — one closing brace, missing the second. Liquid template parsing caught the typo at SEND time, the worker marked the job `failed`, the orphan reconciler resurrected the enrollment on the next tick, and the new job hit the same parse error. Repeat indefinitely. LL#262/F7 (Apr 30) added a dead-letter classification at the worker — but the underlying typo was still in the campaign config, with no UI signal what to fix.
Fix: as of May 1, 2026 (LL#277), Liquid syntax errors are caught at SAVE time on the platform side. Every template-bearing field — invite note, message template, InMail subject + body, comment template, A/B variants — is validated before the campaign is saved. If you have a typo, you'll see a soft amber Alert at the top of the editor with the message 'Step N has a template syntax error', a red border on the offending field, and a caret marker pointing at the typo. The new 'Validate' button on every step gives instant client-side feedback without a save round-trip.
Common typo shapes:
- `{{company}` → fix to `{{company}}` (missing one closing brace)
- `{firstName}}` → fix to `{{firstName}}` (missing one opening brace)
- `{ {firstName}}` → fix to `{{firstName}}` (no space between the braces)
- `{{firstName | default: 'there'}` → fix to `{{firstName | default: 'there'}}` (missing one closing brace)
What to do: open the affected campaign in the editor. The validator banner will tell you which step has the typo. Click 'Validate' on that step to highlight the exact position. Fix the typo, click Save, click Resume on the campaign card. Affected prospects retry on the next tick. No prospects are lost — they sit in `pending_template_fix` until you fix the template. No LinkedIn API calls were made on the failed attempts (the parse error fires BEFORE the Unipile call), so there's no rate-limit cost to retrying.
- Why does connecting a new account give a CHECK constraint error?
Symptom: Dipti and Panagiotis (and 4 others) reported the Reconnect button failing with a confusing `errcode 23514 — new row violates check constraint` error. Every reconnect attempt aborted with no actionable signal.
Why (LL#276, May 1, 2026): the post-OAuth completion path in `server/linkedin-accounts.ts` had 5 remaining inline `UPDATE linkedin_accounts SET status='connected'` statements that bypassed the canonical `flipLinkedinAccountStatus` helper. The LL#246 audit-pairing trigger correctly rejected the orphan flip (a status change without a paired audit row), but the user only saw the cryptic 23514 error.
Fix: all 5 inline UPDATEs refactored to call `flipLinkedinAccountStatus` — each site now records a paired audit row in the same transaction; the trigger passes; the reconnect button works. Added a CI grep guard so the next inline writer fails the build immediately. Backfill flipped 14 stuck `connecting` rows to `connected` via the helper (Dipti, Panagiotis, +12).
What to do: try reconnecting again. The button now works first-try. If you still see 23514, contact support immediately — we want to find and fix any remaining writer.
FAQ
Q: Will my counter ever drift again? A: The reconciler now AUTHORITATIVELY recomputes from event rows + inflight on every 30-min tick — it can't lock in a poisoned cached value anymore. We also added a Slack alert that fires on `daily_invite_counter > 0 AND ground_truth_count_today = 0 for >2h`. Drift will be detected within hours and healed automatically.
Q: Will my prospects be lost if my template has a syntax error? A: No. Affected prospects sit in `pending_template_fix` status with the failed_classification recorded for forensics. They retry on the next campaign tick after you fix the template and click Resume. No prospects are dropped, no quota is consumed, no LinkedIn API calls are wasted.
Q: How do I know if my campaign hit the LL#274 NSA-lag race? A: Check `campaign_prospects.linkedin_status` for any prospect that's been in 'ready_to_invite' or 'ready_to_message' for more than 5 minutes past its `next_action_at` timestamp. As of May 1, the May-1 backfill re-clamped 1,247 stuck enrollments across 23 workspaces. If you have any remaining post-fix, contact support and we'll re-clamp manually.
Q: Why does the Validate button matter if the server validates on save anyway? A: Defense in depth + UX. The server-side validation is the load-bearing safety gate; the client-side Validate button gives you instant feedback without a save round-trip, so you can fix typos in the editor before clicking Save. Both use the same logic — there's no way for a typo to be 'OK at validate time but rejected at save time'.
Q: Will any of these fixes affect my LinkedIn account safety? A: No. Every fix on this page is DB-only — zero new Unipile API calls, no LinkedIn rate-limit risk, no account flags. The engine respects per-account daily/weekly limits, sending windows, and timezone exactly as you configured them. The F1 reconciler fix actually IMPROVES safety by ensuring the rate-limit gate isn't blocked by stale inflated values; the F4 audit-trigger fix means every status flip on your account is now forensically recorded. We never auto-shortcut a step, never auto-skip a wait, and never burn prospects to 'help' you.
- Why is my prospect's next-step scheduled days into the future?
Symptom: you check a prospect's detail page and see their `next_action_at` (the timestamp the engine plans to fire the next step) is 1, 2, or 3 days into the future — even though the prospect is on step 0 (no actions sent yet) and the campaign is running normally. Anisha's PIPL Connection campaign had this on 89 prospects after the May 1 counter heal — counter showed 12 (correct), cap was 80 (plenty of headroom), but the per-prospect NSAs were stamped May 2-4 and the campaign sat idle.
Why (LL#279, May 1, 2026): when the daily counter was inflated during the counter-drift era (e.g. counter=142 vs reality=12), every spurious `daily_limit_reached` trip stamped `next_action_at = next_window_day_start + jitter` to defer the prospect. Each cap-hit pushed NSA one full day forward. Multiple cap-hits during a single inflated day compounded — Anisha had 3 spurious cap-hits in the Apr 28-30 window, so step-0 prospects ended up with NSAs 3 sending-window-days in the future. The May 1 counter heal (LL#273) corrected the gate, but the historical NSA defers remained baked into the prospect rows.
Fix: a one-shot heal script (`server/scripts/heal-stale-future-nsa-2026-05-01.ts`, DRY_RUN default + --commit + CONFIRM=yes gates) re-clamps `next_action_at` for step-0 enrollments whose NSA is > 24h ahead AND whose LinkedIn account had a downward-counter-heal entry within 24h. The clamp uses the canonical `clampToSendingWindow` helper (LL#274 invariant), and the paired `campaign_send_jobs.run_at` UPDATE happens in the same transaction so both timestamps stay aligned. We are healing this automatically — you don't need to do anything.
What to do: nothing. The heal sweep runs on every active LinkedIn account that had a counter heal in the last 24h. If you still see future-dated NSAs > 24h ahead on step-0 prospects after the sweep, contact support — that signals an edge case the sweep didn't catch. Future-proofing: the daily-limit writer is being hardened so a false-positive cap-hit (counter > actuals + inflight + buffer) doesn't stamp future NSAs at all — instead it retries in 5 minutes.
- Why does my dashboard show acceptances but my campaign isn't progressing?
Symptom: Talha's EU SaaS Outreach campaign — 31 prospects accepted his invite (verified in his LinkedIn inbox + the Unipile-side event log), but the WarmySender side never received any `invite_accepted` events. Dashboard showed 0 accepts; campaign never advanced past step 0 (`wait_accept`). Talha had reconnected his LinkedIn account on Apr 28 after a routine ~30-day token rotation.
Why (LL#280, May 1, 2026): Unipile's webhook subscriptions are scoped per-session-token. When LinkedIn rotates the token and you reconnect, the OLD subscription is implicitly invalidated. Our reconnect path was correctly flipping the account status back to `connected`, but it wasn't re-registering the per-account webhook subscription on Unipile's side. So every reconnect silently dropped the relations webhook (`users.relations.new_relation` + `users.invitations.accepted_by_other_party`) for that account until the next platform-wide subscription refresh (currently weekly). Net: every accept event landed on Unipile's side but never reached our database, so the campaign's wait_accept condition never advanced and the dashboard showed 0 accepts.
Fix: the reconnect path now calls `unipile.subscribeWebhook(accountId, [<full event list>])` AFTER `flipLinkedinAccountStatus(accountId, 'connected')` succeeds. Wrapped in try/catch — failure logs `[WEBHOOK-REREGISTER-FAILED]` and inserts a `linkedin_admin_alerts` row but doesn't roll back the reconnect (the alert lets ops re-register manually). Future-proofing: a daily cron (`linkedinWebhookSubscriptionAudit`) compares `unipile.listWebhookSubscriptions()` against active `linkedin_accounts` rows — any account missing the relations event triggers automatic re-registration with admin alert. Cross-references the Unipile docs at developer.unipile.com.
What to do: if you've reconnected your account at any point in the last 30 days and your dashboard shows fewer accepts than your LinkedIn inbox, click Reconnect once more (the new path re-registers webhooks correctly), then wait 5 minutes for the audit cron to back-fill any missed events from the polling fallback. Tip: if you see consistent acceptance gaps after reconnect, contact support with the affected campaign IDs and we'll force-replay the missed events from the Unipile event store. No prospects are lost; the inbound events are durable on Unipile's side and replayable on demand.
- Why does 'last activity' show an old date even though my campaign is sending?
Symptom: Mark Eagar's LinkedIn account dashboard tile showed 'last activity 47 minutes ago' but his campaign was actively sending — `campaign_send_jobs.completed_at` for his account had 6 rows within the last 10 minutes. The 'last activity' pill was lying.
Why (LL#281, May 1, 2026): the 'last activity' UI tile was wired to `linkedin_accounts.last_sync_at` — a column updated by the Unipile session refresh cron (every 5 minutes during business hours, less frequently outside). It reflects auth-flow health, NOT send activity. Same family as LL#247 (status-pill honesty: 'Connected on dd/mm/yyyy' lied on disconnected rows). The CORRECT signal for 'last activity' is 'did this account complete a real send recently?', which lives in `campaign_send_jobs.completed_at WHERE status='completed' AND linkedin_account_id = ?`.
Fix: a new API endpoint `GET /api/linkedin/accounts/:id/last-activity` returns `MAX(completed_at)` from `campaign_send_jobs` joined to `linkedin_account_id`, derived per-request. The UI tile now reads from this endpoint instead of the row-level `last_sync_at`. Fallback chain: if no completed jobs in the last 7 days, falls back to `MAX(linkedin_events.created_at)` (covers replies + acceptance webhooks); falls back FINALLY to `last_sync_at` only if both are NULL. The pill's tooltip explains the source explicitly: 'Last completed send at HH:mm UTC' / 'Last LinkedIn event at …' / 'Last health check at …' — full transparency.
What to do: hard-refresh your dashboard (60s cache). The tile should now reflect your most recent send-completion timestamp. Hover over the pill to see the source explanation. If the timestamp still doesn't match your ground truth (the prospect's enrollment activity log + your LinkedIn sent items), contact support — that's a counter desync we want to investigate.
References: LL#273 (counter reconciler RAISE-only), LL#274 (NSA-lag clock-snapshot race), LL#275 (phantom-accept-reverted exclusion), LL#276 (`connectAccountDirect` audit-trigger gap), LL#277 (Liquid template syntax validator at SAVE time), LL#278 (stats refresher rotation nudge), LL#279 (counter-inflation NSA cascade heal), LL#280 (webhook subscription continuity across reconnects), LL#281 (last-activity tile derived from `campaign_send_jobs.completed_at`). PRD: PRD-2026-05-01-LINKEDIN-USER-COMPLAINT-AUDIT-V6.md.
Why are some prospects parked waiting for a template fix?
If your campaign edit page shows a soft amber banner that says "N prospects waiting for this template to be fixed", those prospects are parked in a special status called pending_template_fix. They are not lost, not failed, and not consuming your LinkedIn account's daily or weekly safety budget. They are simply waiting for the template to validate cleanly before the engine attempts the next send.
What put them there
When the engine renders a step's message body just before sending, it parses the template's Liquid syntax (the {{firstName}}, {{company}}, {{custom.subject_line}} merge tags). If the parse fails — most commonly because of a typo like {{company} with one closing brace instead of two, or a referenced custom field that no longer exists in your prospect data — the engine deliberately does NOT send a broken message. It parks the prospect in a Pending Template Fix state and pauses scheduling until the template is fixed. This is the defer-not-burn pattern: parked prospects retry on the next campaign tick after the template is saved valid; they are not terminally failed.
What saving a valid template does (auto-resume)
As of early May 2026, saving a campaign with a valid template triggers an automatic re-arm pass on the platform side. The instant your save succeeds, the engine looks for every prospect on this campaign in the Pending Template Fix state, clears the sentinel for them, sets a fresh next-action timestamp in the next valid sending window (with 0 to 30 minutes of jitter so the engine doesn't get a synchronized burst), and updates the paired send job so the worker fires on the same timestamp the engine sees. The amber banner disappears on your next page refresh once the prospects clear out.
You don't need to click Resume after saving. The auto-resume handles the transition for you. If the banner is still there after a hard-refresh, it means some prospects are still waiting on a template that's still failing validation — open the affected step in the editor and use the Validate button to spot the typo. The validator points a caret marker at the exact position of the parse error.
Why we surface this on the edit page
The banner is non-destructive (soft amber, ARIA role="status" with aria-live="polite") and the page stays fully usable while it's visible. We surface it on the edit page specifically — not just on the campaign detail / running view — because the fix lives in the editor: open the affected step, fix the template, save. That's the action that resolves the parked prospects.
The banner shows the count of prospects currently parked for a template fix on this campaign. If you have a larger cohort spread across multiple campaigns or templates that have been parked since before the auto-resume hook landed, our team ran a one-shot retroactive re-arm that scans every stuck prospect, validates the current template, and re-arms anyone whose template now passes. Prospects whose template still fails validation are the ones the banner surfaces — they need a real fix in the editor before they resume.
Common questions
Will I lose prospects parked here? No. They sit in the parked status with a forensic record of the parse error attached for support. They retry on the next campaign tick after a valid save. No quota is consumed and no LinkedIn API call was wasted on the failed attempts (the parse error fires before any LinkedIn call).
Will this affect my LinkedIn account safety? No. Defer-not-burn is purely an internal state-flip. Zero new LinkedIn API calls, no rate-limit risk, no account flags. The engine respects per-account daily and weekly limits, sending windows, and timezone exactly as you configured them. Account safety always wins over throughput.
What if my template validates but the banner stays? Hard-refresh the page (60-second cache on the count). If the banner persists, contact support at hello@warmysender.com with the campaign name — there may be additional prospects parked under a related template that also needs review.