Resource

Webhooks

Register one HTTPS URL. FundingScout POSTs a match payload to it within ~60 seconds of any funding round matching your synced accounts/contacts. Don't want to host a webhook? Use the pull endpoint instead.

POST /api/v1/webhooks

Set or update your webhook URL. The URL must be HTTPS.

curl -X POST https://fundingscout.io/api/v1/webhooks \
  -H "Authorization: Bearer $FS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.example.com/hooks/fundingscout"}'

# Response:
# { "webhook_url": "https://yourapp.example.com/hooks/fundingscout" }

Clearing the webhook

Pass null or empty string to switch to pull-only mode:

curl -X POST https://fundingscout.io/api/v1/webhooks \
  -H "Authorization: Bearer $FS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": null}'

# Response: { "webhook_url": null }

GET /api/v1/webhooks

Read your current webhook URL.

curl https://fundingscout.io/api/v1/webhooks \
  -H "Authorization: Bearer $FS_KEY"

# Response: { "webhook_url": "https://..." | null }

Webhook payload

This is what FundingScout POSTs to your endpoint when a match fires:

POST https://yourapp.example.com/hooks/fundingscout
Content-Type: application/json
User-Agent: FundingScout-Webhook/1.0
X-FundingScout-Signature: sha256=<hex>      // see "Verifying signatures" below

{
  "event": "funding_match",
  "match_id": "f1b2c3d4-...",
  "match_type": "account_domain",        // "account_domain" | "email_domain" | "account_name"
  "matched": {
    "account_external_id": "0014x000007xY3oAAE",
    "contact_external_id": null,          // populated when match_type is "email_domain"
    "account_metadata": {                 // verbatim JSONB you sent on POST /accounts
      "alphaflow_customer_id": "vc_42"
    },
    "contact_metadata": null              // verbatim JSONB from POST /contacts (or null)
  },
  "funding_round": {
    "id": "9e34cb64-...",
    "company_name": "Vellum AI",
    "amount_usd": 20000000,
    "funding_type": "series-a",
    "website": "https://vellum.ai",
    "article_url": "https://www.businesswire.com/...",
    "article_title": "Vellum Raises $20M Series A...",
    "published_date": "2026-05-12",
    "ceo_name": "Akash Sharma",
    "ceo_email": "akash@vellum.ai",      // may be null if enrichment hasn't run yet
    "ceo_linkedin_url": "https://linkedin.com/in/akash-sharma",
    "industry": "AI Infrastructure",
    "location": "San Francisco",
    "location_country": "US",
    "lead_investor": "Spark Capital",
    "investors": ["Spark Capital", "Sequoia", "..."],
    "confidence_score": 0.95
  },
  "timestamp": "2026-05-12T01:54:00Z"
}

Required response

Respond with any 2xx status code (200, 201, 204) to acknowledge. Response body is ignored but logged for debugging. We expect a response within 5 seconds — slower than that and we consider the delivery failed.

Verifying signatures (HMAC)

Every webhook is signed with HMAC-SHA256 using the webhook secret (fs_whsec_...) shown to you once when your API key was created. The signature is sent in the X-FundingScout-Signature header as sha256=<hex>. Recompute the same value on your side and compare to confirm the request is from us and the body wasn't tampered with.

Lost your secret? Rotate it under Settings → API Keys — the old one stops working immediately, so coordinate with your webhook receiver before rotating in production.

Node.js / Express

import crypto from 'node:crypto'
import express from 'express'

const app = express()
const WEBHOOK_SECRET = process.env.FS_WEBHOOK_SECRET  // fs_whsec_...

// IMPORTANT: capture the RAW body — JSON.parse() loses whitespace which
// invalidates the signature.
app.post('/hooks/fundingscout',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = 'sha256=' + crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body)                                 // Buffer of raw body
      .digest('hex')

    const got = req.header('X-FundingScout-Signature') || ''
    if (
      got.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got))
    ) {
      return res.status(401).send('invalid signature')
    }

    const payload = JSON.parse(req.body.toString('utf8'))
    // ...process the funding_match event
    res.status(200).send('ok')
  })

Python / FastAPI

import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = os.environ['FS_WEBHOOK_SECRET']  # fs_whsec_...

@app.post('/hooks/fundingscout')
async def fundingscout(request: Request):
    raw_body = await request.body()                  # bytes
    expected = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    got = request.headers.get('X-FundingScout-Signature', '')
    if not hmac.compare_digest(expected, got):
        raise HTTPException(status_code=401, detail='invalid signature')

    payload = await request.json()
    # ...process the funding_match event
    return {'ok': True}

Delivery semantics

  • At-most-once. We won't double-fire for the same (your_user_id, funding_round_id) pair, even if multiple match paths fire.
  • No automatic retries today. If your endpoint returns 4xx/5xx or times out, the match is marked failed but no retry happens. Recover via the pull endpoint with ?status=failed.
  • ~60 second latency. Our press-monitoring pipeline runs every minute. From the moment a funding article hits the wire to your webhook firing: usually under 60 seconds.
  • HMAC-SHA256 signed. Verify via the X-FundingScout-Signature header using the secret shown at key creation. See the snippets above.

Recommended receiver pattern

Keep your handler fast (under 1 second). Don't do expensive work synchronously — enqueue it and return 200 immediately.

// Node.js / Express example
app.post('/hooks/fundingscout', async (req, res) => {
  // 1. Acknowledge fast
  res.status(200).send('ok')

  // 2. Process async — don't block the webhook response
  const { match_type, matched, funding_round } = req.body

  // 3. Route to your downstream system
  if (matched.contact_external_id) {
    await sendSlackAlertToContactOwner(matched.contact_external_id, funding_round)
  } else if (matched.account_external_id) {
    await createCRMTaskForAccount(matched.account_external_id, funding_round)
  }
})

URL validation

We reject obviously-bad URLs at registration time:

  • Must be HTTPS (http:// is rejected)
  • Cannot be localhost, 127.0.0.1, or ::1
  • Cannot end in .local (mDNS hostnames are dev-only)