Server webhooks
Server-to-server submission with a long-lived Bearer token. This is the surface to reach for first: your backend, or any tool that lets you set a custom header (Zapier, Make, most webhook-relay UIs), can be sending leads in a few minutes.
Endpoint
POST https://app.blackglassleads.com/api/v1/ingest
Create a server token in Settings → Ingest tokens, then send it in the Authorization header:
Authorization: Bearer ingt_srv_XXXXXXXXXXXXXXXXXXXXXXXX
Payload
The body is arbitrary JSON — there's no fixed schema, and every key becomes a field on the lead. Keep keys flat and top-level (email, name, company) rather than nested objects: your form's field configuration and the lead table both address fields by their top-level key, so a flat payload is what shows up cleanly as columns. Nested values are still accepted and stored, just not surfaced the same way.
curl -X POST "https://app.blackglassleads.com/api/v1/ingest" \
-H "Authorization: Bearer ingt_srv_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{"email":"sarah@acme.com","name":"Sarah Chen","company":"Acme"}'
const res = await fetch('https://app.blackglassleads.com/api/v1/ingest', {
method: 'POST',
headers: {
'Authorization': 'Bearer ingt_srv_XXXXXXXXXXXXXXXXXXXXXXXX',
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: 'sarah@acme.com',
name: 'Sarah Chen',
company: 'Acme'
})
})
const data = await res.json()
<?php
$payload = json_encode([
'email' => 'sarah@acme.com',
'name' => 'Sarah Chen',
'company' => 'Acme',
]);
$ch = curl_init('https://app.blackglassleads.com/api/v1/ingest');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ingt_srv_XXXXXXXXXXXXXXXXXXXXXXXX',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
curl_close($ch);
import requests
response = requests.post(
"https://app.blackglassleads.com/api/v1/ingest",
headers={"Authorization": "Bearer ingt_srv_XXXXXXXXXXXXXXXXXXXXXXXX"},
json={"email": "sarah@acme.com", "name": "Sarah Chen", "company": "Acme"},
)
Response contract
A successful submission returns 202 Accepted:
{ "submission_id": "a1b2c3d4-e5f6-4789-9abc-def012345678" }
Every failure — missing or invalid token, wrong token type, a suspended workspace, a rate-limit trip, a malformed idempotency key, an oversized body — returns an indistinguishable 200 OK:
{ "status": "rejected" }
401 and a rate-limited valid token got a 429, the response code itself would tell an attacker whether a guessed token exists. Making every rejection reason look identical on the wire closes that side channel — the tradeoff is that you can't tell from the response alone why a submission was rejected either. To find out, open Settings → Ingest tokens → Diagnostics, which shows the real, discriminated reason for every rejection against your workspace.Idempotency
Include an X-BGL-Idempotency-Key header (any string, 1–255 characters) to make retries safe. A repeat request with the same key from the same workspace within 24 hours returns the original response verbatim instead of creating a second lead.
curl -X POST "https://app.blackglassleads.com/api/v1/ingest" \
-H "Authorization: Bearer ingt_srv_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "X-BGL-Idempotency-Key: order-12345" \
-H "Content-Type: application/json" \
-d '{"email":"sarah@acme.com"}'
A malformed idempotency key (empty, or over 255 characters) is itself an opaque-reject condition — check the Diagnostics view if retries aren't behaving as expected.
Rate limits
Per workspace, across every server token you've issued:
- 1,000 submissions / minute
- 50,000 submissions / day
Going over either limit returns the same opaque 200 { "status": "rejected" } — there's no 429, for the same anti-enumeration reason as above. The Diagnostics view distinguishes rate-limit rejections from every other rejection reason.
See Headers, errors & limits for the full picture across both server-side surfaces.