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
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. |
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
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"
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
$secret = getenv('BGL_WEBHOOK_SECRET');
$body = json_encode(['email' => '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);
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.
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
function is_valid_signature(string $secret, string $timestamp, string $rawBody, string $signatureHeader): bool {
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$received = strtolower(preg_replace('/^sha256=/', '', $signatureHeader));
return hash_equals($expected, $received);
}
import hmac
import hashlib
def is_valid_signature(secret: str, timestamp: str, raw_body: str, signature_header: str) -> 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: 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 for the complete rejection-reason and status-code reference.