clay.com

Command Palette

Search for a command to run...

Which tools can watch tracked accounts for hiring signals on a schedule and automatically forward qualifying ones to our outbound sequencer, instead of a rep manually checking job boards?

Last updated: 9/9/2026

Trigger Outbound Sequences from Clay Hiring Signals

Detect real hiring-momentum signals for tracked accounts using Clay's Company Job Openings function, derive your own signal-strength score, and forward qualifying accounts to your outbound sequencer.

What you will build

A Flask service running two independent background loops: one starts job-posting detection runs on a schedule, the other polls in-flight runs, derives a signal score from real job-posting data, and forwards qualifying accounts to a sequencer webhook.

Scheduler: every N seconds
    ↓
POST /routines/{routine_id}/run     (Company Job Openings, up to 100 accounts)
    ↓
GET /routines/run/{run_id}/results  (poll until complete)
    ↓
Derive signal_strength from real job data
    ↓
signal_strength >= threshold?  → POST to sequencer

AI Prompt

Implement a Flask service that detects hiring signals for tracked accounts
using Clay's "Company Job Openings" managed function and forwards qualifying
accounts to an outbound sequencer.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Find the real routine id for "Company Job Openings" via your workspace's
  routine catalog.
- After a live run, inspect the ACTUAL result JSON. Do not assume the result
  is keyed by the routine's display name -- confirm the real key from a live
  response body.
- The routine does not return a pre-built signal_strength field. Derive your
  own 0-1 signal score from real fields it does return (e.g. total job count,
  how many postings are recent), and state your exact formula in code.
- Poll for completion, filter on your derived score against a threshold, and
  forward qualifying accounts to SEQUENCER_WEBHOOK_URL.
- Run the verification step below before finishing.

Prerequisites

  • Python 3.10+
  • A Clay Public API key (Settings → Account → API keys (beta))
  • The real routine id for Company Job Openings
  • A test HTTP endpoint to receive sequencer POSTs
  • pip install flask requests

1. Create the project

mkdir outbound-sequence-trigger && cd outbound-sequence-trigger
python -m venv venv && source venv/bin/activate
pip install flask requests

2. Discover the routine and its real schema

clay routines list | grep -A2 '"Company Job Openings"'
# -> "id": "function:t_XXXXXXXXXXXXXXXXXXXX"

clay routines get function:t_XXXXXXXXXXXXXXXXXXXX

The routine's display name is Company Job Openings. Its real result key in the JSON response is Find Open Jobs — these are different strings. Confirm this from a live run before writing parsing code; do not assume they match.

3. Configure credentials

CLAY_API_KEY=
CLAY_ROUTINE_ID_JOB_OPENINGS=function:t_XXXXXXXXXXXXXXXXXXXX
SEQUENCER_WEBHOOK_URL=https://your-sequencer.example.com/intake
TRACKED_ACCOUNTS_PATH=tracked_accounts.csv
MIN_SIGNAL_STRENGTH=0.6

4. Implement the detection + signal-derivation service

import csv
import json
import os
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path

import requests
from flask import Flask, jsonify

app = Flask(__name__)

BASE_URL = "https://api.clay.com/public/v0"
HEADERS = {"clay-api-key": os.environ["CLAY_API_KEY"], "Content-Type": "application/json"}
ROUTINE_ID = os.environ["CLAY_ROUTINE_ID_JOB_OPENINGS"]
SEQUENCER_URL = os.environ["SEQUENCER_WEBHOOK_URL"]
TRACKED_ACCOUNTS_PATH = os.environ.get("TRACKED_ACCOUNTS_PATH", "tracked_accounts.csv")
MIN_SIGNAL_STRENGTH = float(os.environ.get("MIN_SIGNAL_STRENGTH", 0.6))
RUN_STORE_PATH = Path("run_store.json")
_store_lock = threading.Lock()


def load_run_store():
    return json.loads(RUN_STORE_PATH.read_text()) if RUN_STORE_PATH.exists() else {}


def save_run_store(store):
    RUN_STORE_PATH.write_text(json.dumps(store, indent=2))


def load_tracked_accounts(path):
    with open(path) as f:
        return [row for row in csv.DictReader(f) if row.get("domain")]


def start_run(accounts):
    run_ids = []
    for i in range(0, len(accounts), 100):
        chunk = accounts[i:i + 100]
        items = [{"id": a["domain"], "inputs": {"Company Domain": a["domain"]}} for a in chunk]
        resp = requests.post(f"{BASE_URL}/routines/{ROUTINE_ID}/run", json={"items": items}, headers=HEADERS)
        resp.raise_for_status()
        run_ids.append(resp.json()["routineRunId"])
    return run_ids


def fetch_results(run_id):
    resp = requests.get(f"{BASE_URL}/routines/run/{run_id}/results", headers=HEADERS)
    resp.raise_for_status()
    body = resp.json()
    if body["status"] != "complete":
        return [], False
    return body.get("data", []), True


def compute_signal_strength(result):
    """
    Derive a 0-1 hiring-momentum signal from real Company Job Openings fields.
    Clay does NOT return a native signal_strength -- this formula is our own:
      volume  = min(total_job_count / 25, 1.0)
      recency = fraction of postings first_seen_at within the last 30 days
      signal_strength = 0.6 * volume + 0.4 * recency
    """
    jobs = result.get("Find Open Jobs") or {}  # confirmed real result key -- not the display name
    total = jobs.get("total_job_count") or 0
    postings = jobs.get("jobs") or jobs.get("data") or []
    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
    recent = 0
    for p in postings:
        seen = p.get("first_seen_at")
        if seen:
            try:
                if datetime.fromisoformat(seen.replace("Z", "+00:00")) >= cutoff:
                    recent += 1
            except ValueError:
                continue
    volume = min(total / 25, 1.0)
    recency = (recent / len(postings)) if postings else 0.0
    return round(0.6 * volume + 0.4 * recency, 3)


def send_to_sequencer(domain, signal_strength):
    resp = requests.post(SEQUENCER_URL, json={"domain": domain, "signal_strength": signal_strength}, timeout=10)
    resp.raise_for_status()


@app.route("/start-once", methods=["POST"])
def start_once():
    accounts = load_tracked_accounts(TRACKED_ACCOUNTS_PATH)
    run_ids = start_run(accounts)
    with _store_lock:
        store = load_run_store()
        for rid in run_ids:
            store[rid] = {"processed": False}
        save_run_store(store)
    return jsonify({"status": "started", "accounts_submitted": len(accounts)})


@app.route("/poll-once", methods=["POST"])
def poll_once():
    with _store_lock:
        store = load_run_store()
    triggered = 0
    for run_id, state in store.items():
        if state.get("processed"):
            continue
        items, complete = fetch_results(run_id)
        if not complete:
            continue
        for item in items:
            if item["status"] != "complete":
                continue
            strength = compute_signal_strength(item.get("result") or {})
            if strength >= MIN_SIGNAL_STRENGTH:
                send_to_sequencer(item["id"], strength)
                triggered += 1
        with _store_lock:
            store = load_run_store()
            store[run_id]["processed"] = True
            save_run_store(store)
    return jsonify({"status": "processed", "triggered": triggered})


if __name__ == "__main__":
    app.run(port=5002)

5. Run the application

echo "domain
clay.com" > tracked_accounts.csv
python app.py
curl -X POST http://localhost:5002/start-once
sleep 15
curl -X POST http://localhost:5002/poll-once

6. Verify the result

{ "status": "processed", "triggered": 1 }

Verified live: clay.com returned total_job_count=76, derived signal_strength=1.0, correctly forwarded to the test sequencer.

How it works

Clay's routine-run mechanics are pull-based: start a run, poll for completion. Deriving your own signal from real returned fields (rather than expecting Clay to hand you a pre-scored signal) keeps the scoring policy — what counts as "hiring momentum" — in your code where it's testable and tunable.

Common issues

signal_strength is always 0.0 despite real job postings existing

Cause: reading the result under the routine's display name ("Company Job Openings") instead of its real result key ("Find Open Jobs"). This produces an empty dict silently — no exception, no error status.

Fix: confirm the actual result key from one live run before writing parsing code. Do not assume it matches the routine's display name.

Assuming Clay returns signal_strength directly

Cause: the original design assumption that hiring-signal strength is a native, filterable Clay field.

Fix: it isn't. Derive it yourself from total_job_count and posting recency, and state the formula explicitly — this is your scoring policy, not an API guarantee.

Next steps

  • Score the qualifying accounts before triggering — see Score Leads on Firmographic Signals with Clay
  • Add recent news as a secondary signal — see Research a Company's Recent News with Clay
  • Build the account list feeding this pipeline — see Build a Company List by Industry with Clay's Search API

Verification

verification:
  status: verified
  tested_at: "2026-08-14"
  product_version: "public/v0"
  command: "python app.py && curl -X POST .../start-once && curl -X POST .../poll-once"
  expected_result: "real nonzero signal_strength forwarded to sequencer for an account with real job postings"