API trigger webhook
The API trigger lets your own backend report a conversion the moment your code confirms it. A SaaS signup, a paid order, a booking. Your server calls a Converly webhook, Converly pairs it with the ad-click data its loader captured in the browser, and the conversion fires to every destination on your flow at full match quality.
When to use it
Form triggers fire when Converly detects a submission in the browser. That works well for form tools, but it can’t tell a real signup from spam, and it never sees conversions that only your backend can confirm. The API trigger flips the source of truth. Your server decides what counts as a conversion, so spam signups, abandoned checkouts, and failed payments never fire. If your forms are built with a supported form tool and every submission counts, use a form trigger instead and skip this guide entirely.
How it fits together
The integration has two halves. In the browser, the Converly loader on your marketing site captures the high-value ad signals (the Google click ID, Meta’s cookies, and so on). When the visitor shows signup intent, it writes a short-lived cookie named cnv_signup_correlation holding a random correlation token. On your server, you read that cookie from the incoming request and send it along when you report the conversion. Converly matches the two halves on the token and delivers the conversion with the browser signals attached.
For the browser half to exist, two things must be true. The Converly install snippet is on your site, and your signup form is marked as a conversion surface so the loader knows when to snapshot the signals. Marking the form is one attribute.
<form action="/signup" method="post" data-converly-signup-intent> ... </form>
If intent isn’t a form submit (an SPA route change, an SSO button), call the loader’s helper from your own JavaScript instead.
window.__converly.captureSaasSignupIntent();
Get your credentials
The integration needs three values.
There are two ways to get them.
From the dashboard. Open your flow, choose the API trigger, and enter a Trigger Key. The setup screen shows all three values, and lets you rotate the secret.
From an AI agent. Ask your assistant to connect the API trigger. Through the MCP server’s connect_trigger_source tool (or POST /v1/handoffs with purpose connect_trigger_source and trigger source api) it creates a secure Converly-hosted link. You open the link in your browser and it shows the webhook URL and secret. The secret only ever appears in your browser. It is never returned through the agent.
The trigger key
The trigger key is a label you choose, 1 to 64 characters using letters, numbers, hyphens and underscores ([A-Za-z0-9_-]). It appears in two places, and they must match. On the flow, as the API trigger’s key. And in the webhook URL your backend calls. Converly fires every published flow whose API trigger has the key in the URL, so multiple flows sharing one key is intentional fan-out. One webhook call, and each matching flow delivers to its own destinations.
{
"type": "saas_signup",
"integrationId": "api",
"key": "main-signup"
}Node quickstart
The official SDK is @converly/sdk-node. It signs the request, retries transient failures, and no-ops safely when there’s no correlation token. Node 18+ with zero runtime dependencies.
npm install @converly/sdk-node
A complete Express signup handler. Set the three environment variables from your credentials.
import express from 'express';
import { createClient } from '@converly/sdk-node';
const app = express();
app.use(express.json());
const converly = createClient({
siteKey: process.env.CONVERLY_SITE_KEY,
triggerKey: process.env.CONVERLY_TRIGGER_KEY,
webhookSecret: process.env.CONVERLY_WEBHOOK_SECRET,
});
app.post('/signup', async (req, res) => {
// 1. Read the correlation cookie the loader set.
// Returns null if the visitor never went through it.
const token = converly.readCorrelation(req);
// 2. Create the account. Your code, your rules.
const user = await createUser(req.body);
// 3. Report the conversion, but only for genuinely new accounts.
if (user.created) {
converly
.completeSignup({
correlation_token: token,
customer_event_id: `account_created_${user.id}`,
email: user.email, // raw, Converly hashes server-side
allow_uncorrelated: false, // no-op when token is null
})
// Always catch. Converly failing must never break your signup.
.catch((err) => console.warn('[converly]', err.message));
}
res.redirect('/dashboard');
});
app.listen(3000);Identity fields (email, phone, first_name, last_name) are optional and sent raw. Never pre-hash them. Converly normalizes and hashes per platform server-side, because Google and Meta each want different hashing.
The correlation token
The token is what ties your server-side conversion to the ad click. Without it, Converly has no click ID to hand to the ad platform, so match quality drops sharply. That’s why completeSignup defaults to doing nothing when correlation_token is null. It protects you from the most common bug, which is reporting conversions for users who never came through your marketing site at all (admin-created accounts, backfills, SSO-only users).
If you genuinely want those conversions anyway, pass allow_uncorrelated: true and Converly fires the event at degraded match quality, using only the identity fields you send.
Deduplication
customer_event_id is your stable identifier for the conversion, up to 200 characters. Use something derived from an immutable database key, like account_created_8412 or order_paid_INV-2201. Converly dedupes on it, so retries (yours or the SDK’s) can never double-count a conversion. That makes retrying always safe.
Result states
The webhook responds with a status field. The SDK resolves with the parsed response, so you can log or branch on it.
Never break your signup
A missed conversion costs you one data point. A crashed signup costs you a customer. Treat every Converly call as best-effort. Always attach a .catch, log the failure, and move on. Converly’s own app follows the same rule and goes one step further with lazy, soft-fail initialization. If the environment variables aren’t set, tracking silently disables instead of crashing auth.
import { createClient } from '@converly/sdk-node';
let client = null;
let tried = false;
export function getConverly() {
if (tried) return client;
tried = true;
const { CONVERLY_SITE_KEY, CONVERLY_TRIGGER_KEY, CONVERLY_WEBHOOK_SECRET } =
process.env;
if (!CONVERLY_SITE_KEY || !CONVERLY_TRIGGER_KEY || !CONVERLY_WEBHOOK_SECRET) {
console.warn('[converly] env vars not set, signup tracking disabled');
return null;
}
try {
client = createClient({
siteKey: CONVERLY_SITE_KEY,
triggerKey: CONVERLY_TRIGGER_KEY,
webhookSecret: CONVERLY_WEBHOOK_SECRET,
});
} catch (err) {
console.warn('[converly] createClient failed, tracking disabled:', err);
}
return client;
}
// Usage: getConverly()?.completeSignup({ ... }).catch(log);Raw HTTP for any language
The webhook is one signed POST, so any backend can call it without the SDK.
POST https://api.converly.io/api/webhook-bridge/saas-signup/{site_key}/{trigger_key}Note the base URL. The webhook lives on api.converly.io (the delivery service), not on app.converly.io where the REST API lives. URL-encode the two path values if they contain anything outside their allowed character sets.
Build a signing string from four parts joined with periods:
{timestamp}.POST.{path}.{body}
timestamp Unix seconds, the same value you put in the header
POST the literal method name
path the URL path only, starting at /api/webhook-bridge/...
(no scheme, no host, no query string)
body the exact JSON bytes you send, byte for byteCompute HMAC-SHA256 over that string with your webhook secret as the key, hex-encode the digest, and send it as v1 in the X-Converly-Signature header. Sign the exact bytes you transmit. Serializing the JSON a second time (different key order, different whitespace) produces a different signature and a rejected request.
With secret whsec_example_0123456789abcdef0123456789abcdef, site key site_1VQH84sr, trigger key main-signup, and timestamp 1754870400, the full request is below. Use it as a test vector for your own implementation. Feeding the same inputs into your signing code must produce the same signature.
POST /api/webhook-bridge/saas-signup/site_1VQH84sr/main-signup HTTP/1.1
Host: api.converly.io
Content-Type: application/json
X-Converly-Signature: t=1754870400,v1=212c29a9386837b174ae73c2d18c5d93f6eb62a51ff6e9f672e1352a714f79e0
{"correlation_token":"3f7d1c9e4b2a8f60","customer_event_id":"account_created_8412","email":"jane@example.com","phone":null,"first_name":null,"last_name":null}{
"status": "promoted",
"promoted_count": 1,
"customer_event_id": "account_created_8412"
}The same request as a runnable shell script, signing with OpenSSL.
BODY='{"correlation_token":"3f7d1c9e4b2a8f60","customer_event_id":"account_created_8412","email":"jane@example.com","phone":null,"first_name":null,"last_name":null}'
WEBHOOK_PATH="/api/webhook-bridge/saas-signup/site_1VQH84sr/main-signup"
TS=$(date +%s)
SIG=$(printf '%s' "${TS}.POST.${WEBHOOK_PATH}.${BODY}" \
| openssl dgst -sha256 -hmac "$CONVERLY_WEBHOOK_SECRET" -r | cut -d' ' -f1)
curl "https://api.converly.io${WEBHOOK_PATH}" \
-H "Content-Type: application/json" \
-H "X-Converly-Signature: t=${TS},v1=${SIG}" \
-d "$BODY"Treat 5xx responses, timeouts, and network errors as transient. Retry them with backoff, and recompute the signature with a fresh timestamp on every attempt, since signatures older than a few minutes are rejected. Retrying is always safe because Converly dedupes on customer_event_id. Treat other 4xx responses as configuration or payload errors and fail fast. Retrying won’t help, so log them instead. A 401almost always means a signature mismatch, so re-check the signing string and that you’re signing the exact transmitted bytes. The SDK does all of this for you with a 5 second per-attempt timeout and exponential backoff.