# Concepts Four objects show up in every integration: - **Workspace** — the container everything else lives in. Your forms, leads, and credentials all belong to exactly one workspace. - **Form** — a named, workspace-scoped definition of the fields you're submitting (`name`, `email`, `company`, …). Every form has a unique **slug**, and optionally a webhook secret for signed submissions. - **Lead** — a single submission. Every lead is attributed to the form it came in through, scored automatically, and (for clean submissions) enriched asynchronously. - **Token / secret** — the credential a given surface authenticates with. Which kind you need depends on which surface you're integrating against — see below. ## Three surfaces, three threat models Black Glass Leads exposes three ways to get a lead in, and they exist because they answer three different questions: *who is sending this, and how much do we trust the channel it arrives on?* | | Surface | Credential | Runs where | | ------ | ------------------------ | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | | **1a** | Server Bearer token | `Authorization: Bearer ingt_srv_…` | Your backend, Zapier, Make — anywhere you can set a custom header and keep a secret server-side | | **1b** | HMAC-signed slug webhook | Per-form webhook secret, signed per-request | A webhook provider posting to a public URL it doesn't control the trust of | | **2** | Browser SDK | Origin-bound, short-lived submission token | The browser — the one place a long-lived secret can never be safe | ### Surface 1a — server Bearer tokens The credential is a long-lived token that never leaves your server. The threat model is simple: as long as the token stays server-side, only you can submit leads with it. This is the surface to reach for first — it's the least ceremony for the most common case (your own backend, or a webhook-relay tool that supports custom headers). See the [server webhooks guide](https://developer.blackglassleads.com/guides/server-webhooks). ### Surface 1b — HMAC-signed slug webhooks Some webhook providers post to a public URL that anyone who finds it could hit — there's no way to keep the URL secret, so the credential can't be "don't tell anyone the endpoint." Instead, every request is signed: a per-form secret produces an HMAC-SHA256 signature over a timestamp and the request body, and the timestamp bounds how long a captured request stays replayable. See the [signed webhooks guide](https://developer.blackglassleads.com/guides/signed-webhooks). ### Surface 2 — browser SDK The browser is the one place you categorically cannot keep a secret — anything shipped to the client is visible to whoever opens dev tools. This surface trades a long-lived credential for a short-lived, origin-bound submission token minted just before use, so a leaked token is worthless outside the page that requested it and expires fast even if it isn't. The browser SDK guide is coming in a later phase of this site — for now, server webhooks or signed webhooks cover every integration we support. # 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 ```text POST https://app.blackglassleads.com/api/v1/ingest ``` Create a server token in **Settings → Ingest tokens**, then send it in the `Authorization` header: ```text 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. ::code-group ```bash [cURL] 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"}' ``` ```js [Node (fetch)] 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 [PHP] '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); ``` ```python [Python] 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`**: ```json { "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`**: ```json { "status": "rejected" } ``` ::note This is intentional, not a bug. If an invalid token got a `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. ```bash [cURL] 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](https://developer.blackglassleads.com/reference/headers-errors-limits) for the full picture across both server-side surfaces. # Signed webhooks For webhook providers that need to sign requests rather than carry a bearer token — the URL itself is effectively public, so every request proves its authenticity with an HMAC signature instead. ## Endpoint ```text POST https://app.blackglassleads.com/api/v1/ingest/:slug ``` `:slug` is your form's slug, shown in **Settings → Forms → [your form] → Delivery**. That same Delivery tab is where you set (or rotate) the form's webhook secret and run the built-in signature debugger — paste a request you sent and it tells you exactly which byte range your signature was computed over vs. what arrived. A webhook secret is **required**. A form with no secret configured rejects every submission — there's no "optional signature" mode, because an unsigned public URL has no way to authenticate the caller at all. ## Headers | Header | Value | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `X-BGL-Timestamp` | Unix seconds. Must be within **±5 minutes** of server time. | | `X-BGL-Signature` | `HMAC-SHA256(secret, "{timestamp}.{rawBody}")`, lowercase hex. An optional `sha256=` prefix is accepted (GitHub-style). | | `X-BGL-Idempotency-Key` | Optional — same 24-hour dedup semantics as [server webhooks](https://developer.blackglassleads.com/guides/server-webhooks#idempotency). | Two details that trip people up: - **Sign the raw bytes, not a re-serialized copy.** The signature covers the exact request body as sent. If your HTTP client re-encodes JSON before signing (different key order, different whitespace) the signature won't match what the server hashes, even though the "same" data was sent. Sign the literal string you're about to put on the wire. - **The signing input is `"{timestamp}.{rawBody}"`, not just the body.** Binding the timestamp into the signature is what makes the replay window enforceable — a captured request can't be replayed against a different (later) timestamp, because that would need a new signature the attacker doesn't have. ## Why the timestamp matters `X-BGL-Timestamp` isn't just a freshness hint — it's part of what's signed. A request more than five minutes off server clock is rejected regardless of whether the signature is otherwise valid, so a captured request-plus-signature pair stops being replayable five minutes after it was issued. Keep your server's clock synced (NTP); skew is measured against server time, not your last deploy. ## Sending a signed request ::code-group ```bash [cURL] SECRET="your-form-webhook-secret" TS=$(date +%s) BODY='{"email":"sarah@acme.com","name":"Sarah Chen"}' SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2) curl -X POST "https://app.blackglassleads.com/api/v1/ingest/your-form-slug" \ -H "Content-Type: application/json" \ -H "X-BGL-Timestamp: $TS" \ -H "X-BGL-Signature: $SIG" \ -d "$BODY" ``` ```js [Node (fetch + crypto)] import { createHmac } from 'node:crypto' const secret = process.env.BGL_WEBHOOK_SECRET const body = JSON.stringify({ email: 'sarah@acme.com', name: 'Sarah Chen' }) const ts = Math.floor(Date.now() / 1000).toString() const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex') await fetch('https://app.blackglassleads.com/api/v1/ingest/your-form-slug', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BGL-Timestamp': ts, 'X-BGL-Signature': sig }, body }) ``` ```php [PHP] 'sarah@acme.com', 'name' => 'Sarah Chen']); $ts = (string) time(); $sig = hash_hmac('sha256', $ts . '.' . $body, $secret); $ch = curl_init('https://app.blackglassleads.com/api/v1/ingest/your-form-slug'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "X-BGL-Timestamp: $ts", "X-BGL-Signature: $sig", ], CURLOPT_RETURNTRANSFER => true, ]); $response = curl_exec($ch); curl_close($ch); ``` ```python [Python] import hmac import hashlib import time import json import requests secret = "your-form-webhook-secret" body = json.dumps({"email": "sarah@acme.com", "name": "Sarah Chen"}) ts = str(int(time.time())) sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() response = requests.post( "https://app.blackglassleads.com/api/v1/ingest/your-form-slug", headers={ "Content-Type": "application/json", "X-BGL-Timestamp": ts, "X-BGL-Signature": sig, }, data=body, ) ``` :: ## Verifying a signature yourself Useful when you're debugging a mismatch locally, or building something that needs to check a signature the same way we do — always with a constant-time comparison, never `===`/`==` on the raw strings. ::code-group ```js [Node (crypto)] import { createHmac, timingSafeEqual } from 'node:crypto' function isValidSignature(secret, timestamp, rawBody, signatureHeader) { const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex') const received = signatureHeader.replace(/^sha256=/, '').toLowerCase() const a = Buffer.from(expected, 'hex') const b = Buffer.from(received, 'hex') return a.length === b.length && timingSafeEqual(a, b) } ``` ```php [PHP] bool: expected = hmac.new( secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256 ).hexdigest() received = signature_header.removeprefix("sha256=").lower() return hmac.compare_digest(expected, received) ``` :: ## Idempotency & rate limits Same as [server webhooks](https://developer.blackglassleads.com/guides/server-webhooks): an optional `X-BGL-Idempotency-Key` header dedupes retries for 24 hours, and the workspace-level limits are 1,000 submissions/minute and 50,000/day, tracked in a separate bucket from Surface 1a so a burst on one surface doesn't trip the other. See [Headers, errors & limits](https://developer.blackglassleads.com/reference/headers-errors-limits) for the complete rejection-reason and status-code reference. # POST /api/v1/ingest Server-to-server lead submission, authenticated with a Bearer token. See the [server webhooks guide](https://developer.blackglassleads.com/guides/server-webhooks) for a walkthrough. ## Request ```text POST https://app.blackglassleads.com/api/v1/ingest ``` | Header | Required | Value | | ----------------------- | -------- | ----------------------------------------- | | `Authorization` | Yes | `Bearer ingt_srv_` | | `Content-Type` | Yes | `application/json` | | `X-BGL-Idempotency-Key` | No | 1–255 char string; dedups retries for 24h | **Body** — arbitrary flat JSON object, max **128 KB**. No fixed schema; every top-level key becomes a lead field. ## Response | Status | Body | Meaning | | ------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `202` | `{ "submission_id": "" }` | Accepted and scored. | | `200` | `{ "status": "rejected" }` | Opaque rejection — see [Headers, errors & limits](https://developer.blackglassleads.com/reference/headers-errors-limits) for why every failure looks the same on the wire. | A `200 { "status": "rejected" }` covers: missing/invalid token, wrong token type, suspended workspace, form not found or inactive, rate limit exceeded, malformed idempotency key, and oversized body. Real, discriminated rejection reasons are visible in **Settings → Ingest tokens → Diagnostics**. ## Rate limits Per workspace: **1,000/minute**, **50,000/day**. See [Headers, errors & limits](https://developer.blackglassleads.com/reference/headers-errors-limits#rate-limits). ## Idempotency An `X-BGL-Idempotency-Key` repeated within 24 hours for the same workspace returns the original `202` response verbatim rather than creating a second lead. # POST /api/v1/ingest/:slug HMAC-signed lead submission, keyed by form slug. See the [signed webhooks guide](https://developer.blackglassleads.com/guides/signed-webhooks) for a walkthrough and worked examples. ## Request ```text POST https://app.blackglassleads.com/api/v1/ingest/:slug ``` `:slug` is the form's slug (**Settings → Forms → [form] → Delivery**). | Header | Required | Value | | ----------------------- | -------- | --------------------------------------------------------------------------------------- | | `X-BGL-Timestamp` | Yes | Unix seconds; within ±5 minutes of server time | | `X-BGL-Signature` | Yes | Hex `HMAC-SHA256(secret, "{timestamp}.{rawBody}")`. Optional `sha256=` prefix accepted. | | `Content-Type` | Yes | `application/json` | | `X-BGL-Idempotency-Key` | No | 1–255 char string; dedups retries for 24h | **Body** — arbitrary flat JSON object, max **128 KB**. A form with no webhook secret configured rejects every request — signing is mandatory on this surface, not optional. ## Response | Status | Body | Meaning | | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `202` | `{ "submission_id": "" }` | Accepted and scored. | | `200` | `{ "status": "rejected" }` | Opaque rejection — see [Headers, errors & limits](https://developer.blackglassleads.com/reference/headers-errors-limits). | A `200 { "status": "rejected" }` covers: form not found/inactive/ambiguous slug, suspended workspace, no webhook secret configured, missing or invalid timestamp, timestamp outside the ±5 minute window, missing or malformed signature, signature mismatch, rate limit exceeded, malformed idempotency key, and oversized body. ## Rate limits Per workspace: **1,000/minute**, **50,000/day** — tracked separately from Surface 1a's buckets. See [Headers, errors & limits](https://developer.blackglassleads.com/reference/headers-errors-limits#rate-limits). ## Idempotency Same 24-hour dedup semantics as [`POST /api/v1/ingest`](https://developer.blackglassleads.com/reference/ingest#idempotency). # Headers, errors & limits Applies to both [`POST /api/v1/ingest`](https://developer.blackglassleads.com/reference/ingest) (Surface 1a) and [`POST /api/v1/ingest/:slug`](https://developer.blackglassleads.com/reference/ingest-slug) (Surface 1b). ## Request headers | Header | Surface | Required | Value | | ----------------------- | ------- | -------- | ----------------------------------------------------------------------------- | | `Authorization` | 1a only | Yes | `Bearer ingt_srv_` | | `X-BGL-Timestamp` | 1b only | Yes | Unix seconds, within ±5 minutes of server time | | `X-BGL-Signature` | 1b only | Yes | Hex `HMAC-SHA256(secret, "{timestamp}.{rawBody}")`, optional `sha256=` prefix | | `X-BGL-Idempotency-Key` | Both | No | 1–255 char string, dedups retries for 24h | | `Content-Type` | Both | Yes | `application/json` | ## Response shapes Both surfaces return exactly one of two shapes — there is no third status code: | Status | Body | When | | -------------- | ------------------------------- | ----------------------------------------------------------------------------- | | `202 Accepted` | `{ "submission_id": "" }` | The submission was accepted and scored. | | `200 OK` | `{ "status": "rejected" }` | Anything went wrong — auth, rate limit, signature, form state, body validity. | ### Why rejections are opaque A distinguishable response per failure mode (`401` for a bad token, `429` for rate limiting, `400` for a bad signature) would let an attacker enumerate valid tokens or form slugs by watching which status code comes back. Every rejection reason — no matter how different internally — collapses to the same `200 { "status": "rejected" }` on the wire. If you need to know *why* a specific submission was rejected, the real reason is logged and visible in **Settings → Ingest tokens → Diagnostics**, scoped to your own workspace. ## Rate limits Per workspace, tracked separately per surface (a burst on 1a doesn't count against 1b's bucket): | Window | Limit | | ---------- | ------------------ | | Per minute | 1,000 submissions | | Per day | 50,000 submissions | Exceeding either limit returns the same opaque `200 { "status": "rejected" }` — there's no `429`. ## Body limits Both surfaces cap the raw request body at **128 KB**. Oversized bodies are rejected before any parsing happens. ## Legacy endpoint sunset The pre-v1 `/api/ingest/:slug` endpoint (optional signature, no replay protection) was deprecated on 2026-05-26 and permanently retired on 2026-06-25 — every request now returns `410 Gone`. If you're still pointed at it, migrate to one of the two endpoints on this page; see the in-app [migration notes](https://app.blackglassleads.com/docs/migration){rel=""nofollow""} for the full cutover checklist.