Skip to content
UpVoiced Docs

Integrations

Zapier

Zapier receives UpVoiced events through a Webhooks by Zapier trigger. A short Code by Zapier step checks the signature first, so a Zap only runs on deliveries UpVoiced really sent.

Receive events

  1. In Zapier, create a Zap. For the trigger choose Webhooks by Zapier with the event Catch Raw Hook. Use Catch Raw Hook, not Catch Hook: the signature is computed over the raw body, and only Catch Raw Hook keeps it unparsed along with the request headers. Copy the webhook URL from the trigger’s Test tab.
  2. In UpVoiced, add a webhook endpoint with that URL (owners and admins: Settings → Integrations → Webhooks, or POST /v1/webhooks). Pick the event this Zap handles. Copy the signing secret (whsec_…): it is shown once.
  3. Make UpVoiced send something: select Send test on the endpoint, or call POST /v1/webhooks/{id}/test, to deliver a signed webhook.test event; to see real fields, create a test post or change its status. Then click Test trigger in Zapier.
  4. Add an action step Code by Zapier and choose JavaScript (or Python). InInput Data, add three keys: raw_body mapped to the raw body of the trigger, signature mapped to its X-UpVoiced-Signature header, and secret with your whsec_… secret typed in. Paste the code below and test the step.
  5. Add the actions you want and map the code step’s output fields. Publish the Zap.

The code step throws an error, which stops that Zap run, when the signature is missing, does not match or is more than 300 seconds old. Zapier answers UpVoiced with a 200 as soon as the trigger catches the request, so a stopped run is not retried; it shows as an error in your Zap history.

Code by Zapier: JavaScript

// UpVoiced webhooks in Zapier: a "Code by Zapier" step (Run JavaScript) right after a
// "Webhooks by Zapier" trigger with the "Catch Raw Hook" event. Server-side (Zapier) only.
//
// Input Data (left: key, right: value):
//   raw_body   the raw request body from the Catch Raw Hook step (unparsed, byte for byte)
//   signature  the X-UpVoiced-Signature request header from the same step
//   secret     the endpoint's signing secret (whsec_...); anyone who can edit the Zap can read it
//
// Stops this Zap run with an error unless UpVoiced signed the body within the last 5 minutes;
// otherwise outputs the event as flat fields for the next steps. Zapier provides inputData and
// output, and runs Node.js with its standard library.
const crypto = require('crypto');

const body = String(inputData.raw_body || '');
const header = String(inputData.signature || '');
const secret = String(inputData.secret || '');
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
  const [key, value = ''] = part.trim().split('=', 2);
  if (key === 't' && /^\d{1,12}$/.test(value)) timestamp = Number(value);
  else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) throw new Error('No UpVoiced signature.');
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300)
  throw new Error('UpVoiced signature is too old.');
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();
const valid = signatures.some((signature) =>
  crypto.timingSafeEqual(Buffer.from(signature, 'hex'), expected),
);
if (!valid) throw new Error('UpVoiced signature does not match.');

const event = JSON.parse(body);
const data = event.data || {};
const post = data.post || {};
const survey = data.survey || {};
const response = data.response || {};
output = {
  // Retries and redeliveries reuse event_id: use it as the unique key of what you create.
  event_id: event.id,
  type: event.type,
  occurred_at: event.occurred_at,
  workspace: (event.workspace || {}).slug || '',
  post_id: post.id || '',
  post_title: post.title || '',
  post_url: post.url || '',
  post_board: (post.board || {}).name || '',
  post_status: (post.status || {}).name || '',
  post_votes: post.vote_count === undefined ? '' : post.vote_count,
  from_status: (data.from_status || {}).name || '',
  to_status: (data.to_status || {}).name || '',
  survey: survey.slug || '',
  survey_type: survey.type || '',
  score: response.score === undefined || response.score === null ? '' : response.score,
  band: response.band || '',
  answer: response.text || '',
  followup: (response.followup || {}).text || '',
};

Code by Zapier: Python

# UpVoiced webhooks in Zapier: a "Code by Zapier" step (Run Python) right after a
# "Webhooks by Zapier" trigger with the "Catch Raw Hook" event. Server-side (Zapier) only.
#
# Input Data (left: key, right: value):
#   raw_body   the raw request body from the Catch Raw Hook step (unparsed, byte for byte)
#   signature  the X-UpVoiced-Signature request header from the same step
#   secret     the endpoint's signing secret (whsec_...); anyone who can edit the Zap can read it
#
# Stops this Zap run with an error unless UpVoiced signed the body within the last 5 minutes;
# otherwise outputs the event as flat fields for the next steps.
import hashlib
import hmac
import json
import re
import time

body = str(input_data.get("raw_body") or "")
header = str(input_data.get("signature") or "")
secret = str(input_data.get("secret") or "")
timestamp = None
signatures = []
for part in header.split(","):
    key, _, value = part.strip().partition("=")
    if key == "t" and re.fullmatch(r"\d{1,12}", value):
        timestamp = int(value)
    elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
        signatures.append(value)
if timestamp is None or not signatures:
    raise Exception("No UpVoiced signature.")
if abs(int(time.time()) - timestamp) > 300:
    raise Exception("UpVoiced signature is too old.")
expected = hmac.new(
    secret.encode("utf-8"), (str(timestamp) + "." + body).encode("utf-8"), hashlib.sha256
).hexdigest()
if not any(hmac.compare_digest(signature, expected) for signature in signatures):
    raise Exception("UpVoiced signature does not match.")

event = json.loads(body)
data = event.get("data") or {}
post = data.get("post") or {}
survey = data.get("survey") or {}
response = data.get("response") or {}


def blank(value):
    return "" if value is None else value


output = {
    # Retries and redeliveries reuse event_id: use it as the unique key of what you create.
    "event_id": event["id"],
    "type": event["type"],
    "occurred_at": event["occurred_at"],
    "workspace": (event.get("workspace") or {}).get("slug") or "",
    "post_id": post.get("id") or "",
    "post_title": post.get("title") or "",
    "post_url": post.get("url") or "",
    "post_board": (post.get("board") or {}).get("name") or "",
    "post_status": (post.get("status") or {}).get("name") or "",
    "post_votes": blank(post.get("vote_count")),
    "from_status": (data.get("from_status") or {}).get("name") or "",
    "to_status": (data.get("to_status") or {}).get("name") or "",
    "survey": survey.get("slug") or "",
    "survey_type": survey.get("type") or "",
    "score": blank(response.get("score")),
    "band": response.get("band") or "",
    "answer": response.get("text") or "",
    "followup": (response.get("followup") or {}).get("text") or "",
}

event_id is the same on every redelivery of an event (you can resend any delivery from the endpoint’s log): use it as the unique key of whatever the Zap creates, so a resent event updates instead of duplicating.

Recipes

Use one endpoint per Zap, each subscribed to one event. If one endpoint carries several events, add a Filter step on the type field (filters need a paid Zapier plan).

New post: post.created

A post is created and is public at once (a post held for approval is not sent as post.created; its approval arrives as post.updated).

Fields to map: post_title, post_url, post_board, post_status, post_votes. Add a row to a spreadsheet, create a card in your planning tool, notify a channel.

Status change: post.status_changed

A post moves to another status, for example from Planned to Shipped.

Fields to map: post_title, post_url, from_status, to_status, post_votes. Update the linked record in your CRM, post a release note draft, alert the team.

Survey response: survey.response.created

A customer answers a Pulse survey (NPS, CSAT, CES and others) on a routing rule that sends responses to your endpoint.

Not subscribable yet. It arrives with Pulse survey routing rules, which send each matching response to a chosen endpoint. The code step already handles it.

Fields to map: survey, survey_type, score, band, answer, followup. Open a follow-up task for detractors, log scores in a sheet, thank promoters.

Create posts from a Zap

Any trigger (a form, a CRM record, a help-desk conversation) can log feedback in UpVoiced through POST /v1/posts:

  1. Add an action step Webhooks by Zapier with the event POST, and set Payload Type to JSON. The POST event escapes mapped text for you; Custom Request sends the data exactly as typed, so quotes or line breaks in a mapped field would break the JSON.
  2. URL: https://api.upvoiced.com/v1/posts.
  3. Data: one row per field below; map the values from earlier steps.
  4. Headers: Authorization = Bearer uv_live_… (an API key with the write scope), X-Workspace = your workspace id, and Idempotency-Key = a value built from the trigger record’s id, for example zap-form- followed by the submission id (8 to 128 characters from [A-Za-z0-9_.:-]).
Data fields
KeyValue
board_idRequired. The board’s id (list boards with GET /v1/boards).
titleRequired. Up to 140 characters.
body_mdOptional description in Markdown.
on_behalf_of_emailOptional. The customer becomes the author and a follower, and gets a “we logged your request” email.

The step’s response is the created post (id, slug, status and more) for later steps. A repeat with the same Idempotency-Key within 24 hours returns the first post instead of creating another.