API reference
Signed CTA context
A CTA configured with forward context tacks a signed payload onto its outbound URL so the destination site can recognise the visitor without re-asking who they are. The payload is encoded as three query-string parameters and signed with an HMAC-SHA256 secret that lives on the workspace.
Behavior rules & CTAs โ pick or
create a link_with_context CTA. Toggle Forward
context and select which fields the destination site
should receive.
Outbound URL shape
When forwarding is on, Pitchbar appends three parameters to the base URL you configured on the CTA:
{base-url}?pitchbar_ctx=<base64url-json>
&pitchbar_ts=<unix-seconds>
&pitchbar_sig=<hex-hmac-sha256>
pitchbar_ctxโ base64url-encoded JSON of the whitelisted fields you chose to forward.pitchbar_tsโ unix timestamp at which the URL was minted; used for replay protection.pitchbar_sigโ hex HMAC-SHA256 of"{pitchbar_ctx}.{pitchbar_ts}"using the workspace'scta_context_secret.
A CTA with no forward-fields selected emits the URL verbatim โ the extra params only appear once the operator opts in.
Whitelisted fields
Pitchbar only ships fields the operator explicitly enabled per CTA, chosen from this whitelist:
| Field | Source |
|---|---|
conversation_id | Active conversation row |
agent_id | Owning agent |
page_url | Page the visitor was on when chat opened |
visitor_email | Latest captured Lead.email |
visitor_name | Latest captured Lead.name |
captured_fields | Lead.fields JSON minus password/token/secret/api_key keys |
Sensitive-looking custom field keys (anything containing
password, pwd, secret,
token, api_key, apikey, or
private) are stripped from captured_fields
before signing โ defence-in-depth.
Find your workspace secret
The signing secret lives on the workspace row as
cta_context_secret. Pitchbar mints one automatically
the first time a CTA emits a signed URL. You can copy it from
Settings โ API tokens (the
Signed CTA context card) and rotate it when you need to
revoke trust on receiving sites.
Replay window
Pitchbar enforces a 5-minute replay window (300s) by
rejecting verifications whose pitchbar_ts is more than
that far from the current time. The verifier on your side
must apply the same check โ without it an attacker
who scrapes the URL out of a referrer log can replay it forever.
Verifying the signature
The pattern is the same in every language: rebuild the HMAC over the
exact ctx.ts string with your workspace secret, compare
against the inbound pitchbar_sig using a
constant-time check, and only then decode pitchbar_ctx
to read the payload.
PHP
$ctx = (string) ($_GET['pitchbar_ctx'] ?? '');
$ts = (string) ($_GET['pitchbar_ts'] ?? '');
$sig = (string) ($_GET['pitchbar_sig'] ?? '');
$secret = getenv('PITCHBAR_CTA_SECRET');
if ($ctx === '' || $ts === '' || $sig === '' || $secret === '') {
http_response_code(401);
exit;
}
// Replay window โ 5 minutes either direction.
if (abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$expected = hash_hmac('sha256', $ctx . '.' . $ts, $secret);
if (! hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
// Signature valid โ decode the payload. Note base64url, not standard base64.
$padded = $ctx . str_repeat('=', (4 - strlen($ctx) % 4) % 4);
$json = base64_decode(strtr($padded, '-_', '+/'), true);
$payload = $json === false ? null : json_decode($json, true);
// $payload now contains the whitelisted fields the operator forwarded.
Node.js
const crypto = require('crypto');
function verifyCtaContext(query, secret) {
const ctx = String(query.pitchbar_ctx || '');
const ts = String(query.pitchbar_ts || '');
const sig = String(query.pitchbar_sig || '');
if (!ctx || !ts || !sig) {
return null;
}
// Replay window.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return null;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ctx}.${ts}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return null;
}
const padded = ctx + '='.repeat((4 - (ctx.length % 4)) % 4);
const json = Buffer
.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
.toString('utf8');
try {
return JSON.parse(json);
} catch {
return null;
}
}
Python
import base64, hmac, hashlib, json, time
def verify_cta_context(query, secret: str):
ctx = query.get('pitchbar_ctx', '')
ts = query.get('pitchbar_ts', '')
sig = query.get('pitchbar_sig', '')
if not ctx or not ts or not sig:
return None
try:
if abs(time.time() - int(ts)) > 300:
return None
except ValueError:
return None
expected = hmac.new(
secret.encode(),
f"{ctx}.{ts}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig):
return None
padded = ctx + '=' * ((4 - len(ctx) % 4) % 4)
raw = base64.urlsafe_b64decode(padded.encode())
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
Example payload
A CTA configured to forward
conversation_id, visitor_email, and
page_url ships:
{
"conversation_id": "01JRX4D8YQ8KEXP3F5VZ8MEXAM",
"visitor_email": "[email protected]",
"page_url": "https://customer-site.com/pricing"
}
base64url-encoded into pitchbar_ctx, paired with the
current pitchbar_ts, and signed with your secret.
Why not the outbound webhook?
The webhook subscription delivers the full conversation transcript asynchronously โ perfect for analytics or CRM push, but useless when a visitor clicks a CTA and you want to land them on a personalised page before any background job finishes. The signed CTA context exists for that fast path: it travels with the click and arrives with the request.
Keep using webhooks for anything that needs the full body, or for leads that you want to fan out to multiple downstream systems. They're complementary, not redundant.