Recipes

Six small, complete programs against the FilingPulse API — four in Python, two in Node. Every script on this page is executed against a live API instance by our regression suite before it ships, and the download links serve the exact tested files. Each one reads FILINGPULSE_API_KEY from the environment — free keys take under a minute. Most use an official SDK (pip install filingpulse / npm install filingpulse); one is plain stdlib HTTP on purpose, to show the API needs no client at all.

Export a ticker’s insider transactions to CSV

Paginates /v1/insider-trades, flattens each filing’s transaction legs into one row per transaction, and coerces the schema’s strings-as-filed numerics to Decimal at the edge — the API never coerces for you, and some filings genuinely state no number.

python export_insider_trades_csv.py KMI --since 2026-01-01

def to_decimal(value):
    """Values arrive exactly as filed; not every filing states every number."""
    if value is None:
        return None
    try:
        return Decimal(value)
    except InvalidOperation:
        return None  # as-filed junk ("N/A", ...) — keep the row, drop the number


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("ticker", help="Issuer trading symbol, e.g. KMI")
    ap.add_argument("--since", help="Only filings filed on/after YYYY-MM-DD")
    ap.add_argument("--out", help="Output path (default <TICKER>_insider_trades.csv)")
    args = ap.parse_args()

    api_key = os.environ.get("FILINGPULSE_API_KEY")
    if not api_key:
        sys.exit("Set FILINGPULSE_API_KEY (free key: https://filingpulse.io/signup.html)")
    fp = FilingPulse(api_key=api_key,
                     base_url=os.environ.get("FILINGPULSE_BASE_URL",
                                             "https://api.filingpulse.io"))

    out_path = args.out or f"{args.ticker.upper()}_insider_trades.csv"
    fields = ["period", "form_type", "owner", "officer_title", "security_title",
              "transaction_date", "transaction_code", "acquired_disposed",
              "shares", "price_per_share", "shares_owned_after", "is_derivative"]

    rows, offset = [], 0
    while True:
        page = fp.insider_trades(ticker=args.ticker, since=args.since,
                                 limit=PAGE_SIZE, offset=offset)
        for filing in page["data"]:
            owner = filing["reporting_owners"][0]
            for tx in filing["transactions"]:
                rows.append({
                    "period": filing["period"],
                    "form_type": filing["form_type"],
                    "owner": owner["name"],
                    "officer_title": owner["officer_title"] or "",
                    "security_title": tx["security_title"],
                    "transaction_date": tx["transaction_date"],
                    "transaction_code": tx["transaction_code"],
                    "acquired_disposed": tx["acquired_disposed"],
                    "shares": to_decimal(tx["shares"]),
                    "price_per_share": to_decimal(tx["price_per_share"]),
                    "shares_owned_after": to_decimal(tx["shares_owned_after"]),
                    "is_derivative": tx["is_derivative"],
                })
        offset += PAGE_SIZE
        if offset >= page["total"]:
            break

Full script: export_insider_trades_csv.py

A daily 8-K digest for one item code

Item 2.02 is “Results of Operations and Financial Condition” — the 8-K companies file when they report results. The same script digests any item code (--item 5.02 for officer changes, 1.01 for material agreements). Output is grouped by filing day; items are presented exactly as classified, nothing more.

python earnings_8k_digest.py --since 2026-08-01

    since = args.since or (dt.date.today() - dt.timedelta(days=7)).isoformat()
    events = fp.events(item=args.item, since=since, limit=args.limit)["data"]
    if not events:
        print(f"No item {args.item} events filed since {since}.")
        return

    caption = next(i["caption"] for e in events for i in e["items"]
                   if i["code"] == args.item)
    print(f"8-K item {args.item} ({caption}) since {since} — {len(events)} filings\n")

    def day(e):  # filed_date is YYYYMMDD as filed
        d = e["filed_date"] or ""
        return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if len(d) == 8 else d

    for filed_day, group in groupby(events, key=day):
        print(filed_day)
        for e in group:
            other = [i["code"] for i in e["items"] if i["code"] != args.item]
            extra = f"  (also items {', '.join(other)})" if other else ""
            print(f"  {e['issuer']['name']}  [{e['form_type']}]{extra}")
        print()

Full script: earnings_8k_digest.py

Verify webhook signatures, then notify Slack

FilingPulse signs every delivery with your subscription’s secret (X-FilingPulse-Signature: sha256=…). Always verify before trusting a payload — anyone can POST JSON at your endpoint; only FilingPulse knows the secret. This receiver is a complete stdlib HTTP server: it rejects bad signatures with 400, prints one factual line per delivery, and forwards it to a Slack incoming webhook if SLACK_WEBHOOK_URL is set.

FILINGPULSE_WEBHOOK_SECRET=whsec_… python webhook_receiver.py --port 8080

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        signature = self.headers.get("X-FilingPulse-Signature", "")
        if not verify_webhook_signature(SECRET, body, signature):
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b"bad signature")
            return
        notify(summarize(json.loads(body)))
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

    def log_message(self, *args):  # signature check already logs what matters
        pass

Full script: webhook_receiver.py

Follow one offering by SEC file number

The SEC file number (333-XXXXXX) is the join key EDGAR itself uses across one offering: the initial S-1/F-1, each amendment, the EFFECT notice, and the final priced prospectus all carry it. Filter /v1/registrations on file_number and the whole thread comes back in one call. Plain urllib — the endpoint works with any HTTP client.

python ipo_lifecycle.py 333-296288

    thread = get("/v1/registrations", file_number=file_number, limit=200)["data"]
    if not thread:
        sys.exit(f"No events on file {file_number}.")
    thread.sort(key=lambda e: e["filed_date"] or "")

    issuer = thread[-1]["issuer"]
    print(f"{issuer['name']} (CIK {issuer['cik']}) — file {file_number}")
    if issuer.get("sic"):
        print(f"  industry: {issuer['sic']} (SIC {issuer['sic_code']})")
    for former in thread[-1].get("former_names", []):
        print(f"  formerly: {former['name']} (until {iso(former['date_changed'])})")
    print()
    for e in thread:
        label = STAGE_LABEL.get(e["stage"], e["stage"])
        print(f"  {iso(e['filed_date'])}  {e['form_type']:<7} {label}")
    print(f"\n{len(thread)} tracked event(s). EFFECT notices for untracked form types "
          "(S-3, S-8, ...) won't appear unless they share this file number.")

Full script: ipo_lifecycle.py

Watch for new filings without re-downloading history

JavaScript. The naive poller re-fetches the whole list every minute and re-handles filings it already processed. This one does the two things that make polling cheap and correct: it narrows the window with since=, and it remembers what it already emitted. The id it dedupes on is accession — the SEC accession number, the unique, stable identity of every filing, present on every object the API returns. It also shows the shape of a well-behaved client: a RateLimitError carries retryAfter, so backing off correctly is four lines.

node watch_filings.mjs --tickers KMI,PRTH --item 2.02

 * One pass over every feed we watch. Returns [{id, text}] newest-first, or null
 * if the tier's rate limit was hit — the caller just tries again next cycle.
 *
 * `accession` is the id: the SEC accession number is the unique, stable identity
 * of every filing, and every object type carries it (Form 4 since 2026-08-08,
 * 8-K and registration objects since 2026-08-17).
 */
async function poll() {
  const found = [];
  try {
    for (const query of tickers.length ? tickers.map((t) => ({ ticker: t })) : [{}]) {
      const page = await fp.insiderTrades({ ...query, since, limit: PAGE });
      for (const filing of page.data) {
        found.push({ id: filing.accession, text: form4Line(filing) });
      }
    }
    if (item) {
      const page = await fp.events({ item, since, limit: PAGE });
      for (const event of page.data) {
        found.push({ id: event.accession, text: eventLine(event) });
      }
    }
  } catch (err) {
    if (err instanceof RateLimitError) {
      console.error(`rate limited — backing off ${err.retryAfter}s`);
      await sleep(err.retryAfter * 1000);
      return null;
    }
    throw err;
  }
  return found;
}

Each cycle prints only what it has not seen before, oldest first:

    // The API returns each list newest-first; print oldest-first so the newest
    // filing is always the last line on your screen.
    const fresh = found.filter((f) => !seen.has(f.id)).reverse();
    for (const f of fresh) {
      remember(f.id);
      console.log(`  ${f.text}`);
    }
    if (first) {
      console.log(`  — ${fresh.length} filing(s) already on record`);
      first = false;
    }
  }
  if (once) break;
  for (let waited = 0; waited < intervalMs && !stopping; waited += 500) {
    await sleep(500);
  }
}

Full script: watch_filings.mjs

Verify webhook signatures in Node — over the raw bytes

JavaScript. Same contract as the Python receiver, with one language-specific trap worth stating plainly: verification runs over the raw request bytes. express.json(), Fastify’s default JSON parser and Next.js route handlers all hand you a parsed object and discard the original bytes — and re-serializing that object produces different bytes, so every signature fails. Read the body first, verify, then parse. (In Express: express.raw({ type: "application/json" }) on this route.) verifyWebhookSignature is async because it uses WebCrypto’s constant-time HMAC verify.

FILINGPULSE_WEBHOOK_SECRET=whsec_… node webhook_receiver.mjs --port 8080

async function readRawBody(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  return Buffer.concat(chunks); // a Buffer IS a Uint8Array — pass it straight through
}

const server = http.createServer(async (req, res) => {
  if (req.method !== "POST") {
    res.writeHead(405).end("post only");
    return;
  }
  const raw = await readRawBody(req);
  const signature = req.headers["x-filingpulse-signature"] ?? "";
  if (!(await verifyWebhookSignature(SECRET, raw, signature))) {
    res.writeHead(400).end("bad signature");
    return;
  }
  // Signature checked against the raw bytes — only now is it safe to parse.
  try {
    await notify(summarize(JSON.parse(raw.toString("utf8"))));
  } catch (err) {
    console.error(`delivery accepted but not handled: ${err.message}`);
    res.writeHead(500).end("handler error"); // non-2xx makes FilingPulse retry
    return;
  }
  res.writeHead(200).end("ok");
});

Full script: webhook_receiver.mjs

Notes

All six scripts also accept FILINGPULSE_BASE_URL to point at a different API base. The Node scripts need Node 18 or newer (they use global fetch and WebCrypto). The examples present filings exactly as filed and interpret nothing — FilingPulse is data infrastructure, not investment advice.