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
failedbut 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-Signatureheader 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)