Cookbook
View as Markdown

Route prospect replies and add leads with Zapier

Two Zaps for Victoria AI, a reply hub that verifies the webhook signature in a Code step and routes replies to Slack, Sheets or email, and one that adds leads.

Last updated

Zapier has no Victoria AI app, and doesn't need one: the REST API and the prospect_response webhook are enough. This recipe is two Zaps. The reply hub receives every reply to a campaign, checks the signature, and sends each one where it belongs. The leads Zap adds a person to a campaign whenever something happens in another app, a new row in a sheet, a form submission, a tagged contact.

Before you start

  • A Zapier plan that includes Webhooks by Zapier and Code by Zapier. Webhooks by Zapier isn't available on the Free plan.
  • An API key with campaigns:write (to register the webhook) and leads:write (for the leads Zap). See Create an API key.
  • A signing secret of your own, at least 16 characters. One way to make one is openssl rand -hex 32.
  • The campaign's id, from its URL in the app or from GET /v1/campaigns.

Zap 1: the reply hub

A campaign has one webhook, so this Zap is the only receiver for the campaign's replies. Branch inside it rather than registering a second Zap.

Step 1: Webhooks by Zapier, Catch Raw Hook

Choose Webhooks by Zapier as the trigger and the event Catch Raw Hook, not Catch Hook. Catch Raw Hook keeps the request body exactly as it arrived, as one string, and includes the request headers; that's what the signature was computed over. Copy the webhook URL Zapier shows.

Leave the trigger's test for later: the sample has to come from a signed request (step 3 of "Register and test" below).

Step 2: Code by Zapier, verify and parse

Add Code by Zapier with Run Javascript. Under Input Data, add two items:

NameValue
bodythe trigger's raw body field
signaturethe trigger's X-Signature-256 header field (under Headers)

Then the code. Put your secret in the SECRET line, or keep it in a Storage by Zapier value and read it there.

const crypto = require("crypto");
const SECRET = "replace-with-your-signing-secret";

const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(inputData.body, "utf8").digest("hex");
const received = inputData.signature || "";
if (expected.length !== received.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
  throw new Error("Invalid webhook signature; this request didn't come from Victoria AI");
}

const event = JSON.parse(inputData.body);
const lead = event.lead || {};
const ai = event.ai_response || {};
output = {
  idempotency_key: event.idempotency_key,
  campaign: event.campaign,
  channel: event.channel,
  sentiment: ai.sentiment || "",
  out_of_office: ai.out_of_office ? "yes" : "no",
  agent_action: ai.agent_action || "",
  goal_status: ai.goal_status || "",
  brief: ai.sdr_brief || "",
  message: event.prospect_message || "",
  first_name: lead.first_name || "",
  last_name: lead.last_name || "",
  company: lead.company || "",
  title: lead.title || "",
  email: lead.email || "",
  linkedin: lead.linkedin_profile || "",
};

A bad signature throws, which stops the Zap run and shows it as errored in the Zap history. Everything after this step works with flat fields such as sentiment and message, so later steps never parse JSON.

Step 3: branch and deliver

Add Paths by Zapier, with one path per destination, each with a condition on the Code step's fields:

  • Positive replies to Slack: condition sentiment exactly matches positive → Slack, Send Channel Message, with text such as *{{first_name}} {{last_name}}* ({{company}}) replied {{channel}} to *{{campaign}}*: {{message}}.
  • Every reply to a sheet: no condition → Google Sheets, Create Spreadsheet Row, mapping each column to a field. Put idempotency_key in its own column; a retried delivery carries the same key, so a duplicate row is easy to spot.
  • Hand-offs to a person: condition agent_action exactly matches escalate → Email by Zapier or Gmail, Send Email with the brief and the message.

A Zap without Paths does the same with a Filter by Zapier step before a single action; a reply that doesn't pass the filter simply ends the run.

Zapier's own retries are rare, but Victoria AI retries a delivery when the Zap doesn't answer 2xx within 75 seconds, so a destination that fails can arrive twice. The idempotency_key column is the record.

Register and test

  1. Turn the Zap on. The trigger's webhook URL is live only while the Zap is on.
  2. Register it on the campaign with POST /v1/campaigns/{campaign_id}/webhooks, sending the secret you put in the Code step. replace: true repoints the campaign's one webhook here if something else held it:
curl -X POST https://api.versionseven.ai/v1/campaigns/$CAMPAIGN_ID/webhooks \
  -H "Authorization: Bearer $VICTORIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"webhook_url\": \"https://hooks.zapier.com/hooks/catch/123456/abcdef/\", \"secret\": \"$VICTORIA_WEBHOOK_SECRET\", \"replace\": true}"
  1. Send a signed example from GET /v1/webhooks/examples to the Zapier URL, so the trigger has a real sample and the Code step can be tested against it:
BODY=$(curl -s https://api.versionseven.ai/v1/webhooks/examples -H "Authorization: Bearer $VICTORIA_API_KEY" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["examples"][0]))')
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$VICTORIA_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "https://hooks.zapier.com/hooks/catch/123456/abcdef/" \
  -H "Content-Type: application/json" \
  -H "X-Signature-256: sha256=$SIGNATURE" \
  --data "$BODY"
  1. In the Zap editor, load the sample in the trigger, test the Code step (it outputs the flat fields), and test each path. Then change one character of BODY, send again without re-signing, and the Code step errors: that's the check working.

Zap 2: add leads to a campaign

Any trigger works: Google Sheets, New Spreadsheet Row, Typeform, New Entry, Airtable, New Record in View. The action is Webhooks by Zapier, Custom Request:

FieldValue
MethodPOST
URLhttps://api.versionseven.ai/v1/leads
Data Pass-Through?No
Datathe JSON below, with trigger fields mapped in
UnflattenYes
HeadersAuthorization → Bearer vk_…; Content-Type → application/json; Idempotency-Key → a value unique to the person and campaign
{
  "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
  "lead": {
    "first_name": "{{First Name}}",
    "last_name": "{{Last Name}}",
    "email": "{{Email}}",
    "company": "{{Company}}",
    "title": "{{Title}}",
    "custom_fields": { "source": "typeform" }
  }
}

Zapier sends the Data field exactly as typed, so a value with a quote in it breaks the JSON; wrap mapped fields that may contain one in a Formatter by Zapier, Text, Replace step first, or keep the mapped fields to names, emails and titles.

For the Idempotency-Key, build a value from the campaign id and the person's email in a Formatter by Zapier step (for example a lowercase join of the two). The same person and campaign then always make the same key, and a Zap run that Zapier replays can't enrol the lead twice. See Idempotency.

What the action returns

Zapier shows the API's response on the step. The outcomes to expect:

ResponseMeaning
201A new lead was created and enrolled; lead_id is in the body.
200The person was already a lead in your organization and was enrolled. lead_created is false.
409 LEAD_ALREADY_IN_CAMPAIGNAlready there. Zapier marks the step errored; add a Filter before the action, or accept the error as "nothing to do".
409 LEAD_SUPPRESSEDThe person is on your Do Not Contact list. Nothing was created.
400 VALIDATION_ERRORA required field is missing or malformed; details.errors lists each. A lead needs first_name, last_name, and email or linkedin_url.
403 TRIAL_LEAD_CAP_REACHEDThe organization is on a free trial and has used its lead quota.
429 RATE_LIMITEDMore than 100 requests a minute to the endpoint. Zapier's auto-replay retries errored runs; see Rate limits.

Next steps