Guides  /  API trigger webhook

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.

Your signup form
<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.

Browser JavaScript
window.__converly.captureSaasSignupIntent();

Get your credentials

The integration needs three values.

Credentials
site_key
public
Your site’s public key, the ?key= value in your install snippet. Also returned as site_key by List sites.
trigger_key
public
A short label you choose for this conversion type, like main-signup. See the next section.
webhook_secret
secret
The HMAC signing secret. One per site, shared by every API trigger on that site. Keep it server-side, never in browser code.

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.

A flow's trigger_config using the API trigger
{
  "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.

Terminal
npm install @converly/sdk-node

A complete Express signup handler. Set the three environment variables from your credentials.

server.js
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);
readCorrelation accepts whatever your framework gives you. An Express request (with or without cookie-parser), a raw Cookie header on req.headers.cookie, or an Express-style req.get('cookie') accessor. It auto-detects and returns the token string or null.

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.

promotedfired
Accepted and queued at full match quality. promoted_count says how many events were created, one per matching flow.
promoted_uncorrelatedfired, degraded
Accepted via allow_uncorrelated: true. Delivered without browser signals, so ad platforms match on identity fields alone.
awaiting_browser_halffires shortly
Your webhook arrived before the browser half. Converly holds it briefly and fires when the browser signals land, or at the merge window’s expiry using what you sent.
no_matching_flowsnot fired
No published flow has an API trigger with this trigger key. Check the key on the flow matches the key in your webhook URL, and that the flow is published.
no_promotionnot fired
The halves correlated but nothing was promoted.
skipped (SDK only)no request sent
The SDK’s local no-op when correlation_token is null and allow_uncorrelated is false. Nothing reached Converly.

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.

converly.js (lazy, soft-fail singleton)
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/api/webhook-bridge/saas-signup/{site_key}/{trigger_key}
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.

Request headers
Content-Type
required
application/json
X-Converly-Signature
required
t={timestamp},v1={signature} where timestamp is Unix seconds and signature is the hex HMAC below.
Request body (JSON)
correlation_token
string | null
The value of the cnv_signup_correlation cookie, or null.
customer_event_id
string, required
Your stable dedup key, 1 to 200 characters.
email
string | null
Raw email. Converly hashes per platform server-side.
phone
string | null
Raw phone number.
first_name
string | null
Raw first name.
last_name
string | null
Raw last name.
Computing the signature

Build a signing string from four parts joined with periods:

Signing string
{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 byte

Compute 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.

Worked example

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
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}
200 OK
{
  "status": "promoted",
  "promoted_count": 1,
  "customer_event_id": "account_created_8412"
}

The same request as a runnable shell script, signing with OpenSSL.

Terminal
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"
Retries and failure handling

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.