10.0: NPS surveys with website interactions (Deep dive) #6
jstanden
started this conversation in
Guides and Tutorials
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Introduction
The Cerb 10 previews of interaction automations I've posted so far have all focused on shortcuts for authenticated workers using toolbars.
You can also create interactions with visitors on any website or portal.
A common use case for website interactions is customer satisfaction surveys. I'll walk through an example of how you'd set up that workflow.
NPS
This example assumes we're sending an NPS (Net Promoter Score) survey to customers to see how satisfied they are with our product/service.
The first step of this survey needs to ask someone how likely they are to recommend us, on a scale of 0 (not at all) to 10 (extremely likely).
In NPS, scores of 0-6 are considered "detractors" (dissatisfied customers who recommend against your brand), 7-8 are "neutrals", and 9-10 are "promoters" (your best advocates).
The next step of the survey asks for an optional comment to add useful details to the given score.
Let's assume we also have these additional requirements:
This example will go into exhaustive detail for instructive purposes. In practice, you could just paste the automation or package and start using the feature.
Cerb
Custom record
First, we'll create a custom record from Search -> Custom Records -> (+).
We also need four custom fields:
If you're familiar with custom fields in Cerb 9.x or earlier, note that you can now specify a human-readable nickname (URI) for custom fields and use that in the records API rather than IDs. This makes it much easier to read and copy examples between Cerb installations.
Create a test email address
By design, we can only send surveys to email addresses that exist in Cerb. Let's create a test user.
Navigate to Search -> Email Addresses -> (+).
Add an email address for
customer@cerb.example
and click the Save Change button.Automation
Next, we'll build the website interaction to gather data from customers and save it in the new custom record above.
Navigate to Search -> Automations -> (+)
Script
We'll build and explain the automation script in steps. You can skip ahead for the finished script.
Inputs
First, we need to define three inputs:
email
is a required text field of typeemail
. This is the email address of the customer filling out the survey. We use this to identify them and link the response back to their account.s
is the required signature hash prefix. This ensures with reasonable certainty that the email address in the link was not tampered with.rating
is an optional number field, which allows us to generate separate links for each rating to skip ahead in the survey. We default this to-1
when it's empty, because0
is a valid rating in the NPS survey.Start and outline
Next we use the
start:
command to define where the automation begins. At this point we'll also include our outline. We'll expand each of those commands in the following sections.As a reminder, in automations we have commands that can include an optional
/name
suffix. The name is required when there are sibling commands of the same type.You can see how the names in the outline already help explain what's going on.
Error reporting
At the very end of our script, we'll add a reference named
&stopWithError:
that we can reuse in multiple places.Automations are written in KATA. In KATA, references are top-level keys that begin with an ampersand (
&
). The contents of a reference are copied by other keys when they use the@ref
annotation.Above, when we encounter an error, we exit in the
await
state.The
await
state pauses the automation as a continuation. This can wait for different types of input.In interactions, the most common await is
form:
, which displays a form for gathering input. The automation takes care of form validation before continuing.For error reporting, we're using a
say:
element to display a simple error message that the survey link was invalid. Then we exit in thereturn:
state with no output. This ends the interaction.outcome/no_email:
Let's implement
outcome/no_email:
This runs an
outcome:
command namedno_email
. Outcomes are conditional. When theif:
istrue
, the commands inthen:
are executed; otherwise they are skipped.An outcome can appear by itself as a simple conditional statement. You can also group multiple outcomes into a
decision:
command, and only the first matching outcome will run. This is similar to aswitch
statement in most programming languages.KATA stands for "Key Annotated Tree of Attributes". In KATA, keys can have
@annotations
that describe their type, formatting, etc.The
if@bool:
key has a@bool
annotation, meaning the value will be treated as a booleantrue
orfalse
. This allows us to use text values like1
,y
,yes
, andon
to representtrue
(and the opposites forfalse
).Any key with an annotation can have its value on multiple lines indented one additional level.
Values in KATA can use automation scripting.
The
{{ }}
syntax is an expression with output. Above we're running three tests using theis
andis not
operators:Tests return an output of
1
when true, and0
when false.The first two tests are simple: we're checking if the
inputs.email
orinputs.s
values are empty.The last test is more complex. Let's break it down:
Recall that we're using the
s
(signature) input to verify the link (specifically the email address) wasn't tampered with. We're using a cryptographic signature to accomplish this.In automation scripting, you can append multiple filters using a pipe (
|
). Filters modify the input on the left and output it. If there are multiple filters, the output of the previous filter becomes the input of the next one.We're using the
is not same as
test to check if two values are not exactly equal (same value and type).On the left we're using
inputs.s
-- the signature fragment from the survey URL.On the right, we're computing a hash fragment for the email address to compare that with the signature from the URL.
|hash_hmac
To compute the hash, we're starting with
inputs.email
-- the email address from the survey URL. We then use the |hash_hmac() filter to generate a unique hash from the email address.The first argument is a secret key of
a1b2c3d4
. In practice, you should use your own secret key.The second argument is the type of hash -- SHA-256 here.
The third argument is whether to return raw bytes (
true
) or hex (false
, default).As an example, the HMAC hex for
customer@cerb.example
using SHA-256 and the secret key above is:This signature is longer than we want to include in a URL.
Instead, we'd prefer to use the first 8 characters because that's random enough to dissuade casual tampering. With 8 characters of hexadecimal, there are 16^8 (4,294,967,296) possible combinations. The full hash is 16^64 (1.16 * 10^77); which is not only unsightly, it's overkill.
Hexadecimal is simpler, but it has much less entropy (16 characters) which makes it "easier" to brute force.
Recall that 8-bit binary has 256 possible values per character ("byte"). Not all of these characters are "printable" (i.e. capable of being displayed as text; as in our URL).
By having HMAC output raw bytes (binary) we can use a different encoding with more entropy.
|base64url_encode
We take the binary output from HMAC above and encode it with the "url" variation of Base64.
Standard Base64 converts binary to text using 64 printable characters:
A-Z
,a-z
,0-9
,+
, and/
.The
+
(space) and/
(path) characters have special meanings when used in URLs, so Base64 "url" encoding replaces them with-
and_
respectively. Otherwise the implementation is identical.When we Base64url encode our HMAC binary output above, we get:
|slice
Because Base64 is encoding 256 binary values into 64 printable values, the output is roughly 33-36% larger than the input (which is still shorter in binary than the hex).
However, now each character of our hash can have 64 URL-friendly values rather than only 16 in hex.
The same 8 character length now gives us 64^8 combinations (281,474,976,700,000). If my math is right, that's about 8,900 years of 1000 guesses per second. Simple monitoring and rate-limiting would prevent brute forcing after a few attempts.
We use the
|slice(0,8)
filter to return only the first 8 characters starting from the left (0th character).This gives us:
That's a much smaller random value to add to URLs to ensure they haven't been tampered with. It would just look like
&s=qbISkLjv
at the end of the link.(We could alternatively use
|slice(-8)
to return the last 8 characters of a string.)If the computed hash does not match the given hash, we run
then@ref: stopWithError
, which exits with our error reference from above.If the hash matches, the automation continues.
This step may seem like overkill. It's explained in detail here for instructive purposes, so you'll understand how to protect any URL you're sending to customers. You can modify this approach to meet your needs and risks.
record.search:
Now let's implement
record.search:
Now that we trust the
email
address given as input (from the URL), we want to look up that record. For our purposes, we need the ID to add to our custom record for the NPS survey response. We could also refer to the customer by name (e.g. "Hello, Kina!"), use their preferred timezone, language, location, etc.The
record.search:
command in automations searches records byrecord_type
using arecord_query
search query.It returns record dictionaries. It can optionally expand deeply nested keys in those dictionaries using the "graph". For instance, returning an email address's contact's organization's country.
Here we're searching
address
records byemail:
address. We're expanding labels and returning the results in a new placeholder namedaddress_record
.Note in the query we're specifying
limit:1
. This returns a single dictionary rather than an array of dictionaries. That saves us the hassle of having to reference the first result. The placeholder will now be either null or a dictionary.We're using the
on_success:
event to check if the returned email address doesn't match our search. This should basically never happen, but it also catches the case where there is no match. In these situations, we exit with ourstopWithError
reference like before.We should now have the recipient's email address record loaded as the
address_record
placeholder.A few notes on query placeholder security
If you're familiar with search queries in earlier versions of Cerb, you probably noticed the
${email}
placeholder looks different than the typical{{placeholder}}
syntax.In Cerb 10+, search and data queries can be "parameterized" when you're dealing with untrusted (user-provided) data.
In this example, the email address is coming from the URL. If we included that directly in the query as a placeholder, a clever attacker could craft an input that results in a query "injection" vulnerability.
For instance, an email address like
junk@cerb.example OR email:*
as a trusted placeholder like:Would be evaluated as:
The query would now output every email address record in the system. With enough information, an attacker could trick the query into returning a specifically targeted email address and then perform actions on their behalf.
This is a common attack vector in web applications called "SQL Injection". The concept is the same here.
To mitigate these attacks, queries can now be "parameterized".
Where a trusted placeholder is evaluated before a query is interpreted ("tokenized"), untrusted "parameterized" placeholders in the format of
${placeholder}
are only evaluated after a query is interpreted. The tampered parameter would always be treated as a single text value, and not additional fields, operators, or commands. It would be unable to change the logic of the query itself.With the
record.search:
command in automations,inputs:record_query_params:
is a dictionary of parameterized placeholders and values. Using KATA, values can be further sanitized with scripting, typecast (e.g.@int
), etc.In our case, we're using
@key
to set the value of${email}
to the same value asinputs.email
. We were already enforcing this value was a valid email address in theinputs:
block at the top.decision/rating:
Let's outline
decision/rating:
We're starting a
decision
calledrating
with two possible outcomes (given
andelse
).If a
rating
input was given, we want to skip ahead to the comment step in the survey. Otherwise, we want to prompt for the rating first.outcome/given
For the first outcome:
The outcome checks if a valid rating was given (a number between 0 and 10). If so, it sets the
prompt_nps
placeholder to that value (using the@int
annotation to convert the text to a number).In this case, the automation would skip past
outcome/else:
.outcome/else
Now we'll implement the second outcome:
Since this outcome is intended to handle "else" (all other cases), it omits the
if:
(it will always be true) and just has commands to run inthen:
.The interaction pauses in the
await
state and displays a form titled "NPS Survey" to the user.The form has one sheet element with the label "How likely are you to recommend Cerb to friends and colleagues?".
Sheets are an incredibly flexible element that shows (static or dynamic) data as a set of columns and rows.
In this case, for an NPS survey, our data is the inclusive sequence of numbers between 0 and 10.
We're displaying the sheet's data as a
scale
layout, which is a horizontal range of values. The left (0) is labeled "Not at all likely" and the right (10) is labeled "Extremely likely".We include two columns.
The first column is a single
selection
using the key's value (0-10). The selected value will be saved to a placeholder based on the sheet element's name (prompt_nps
). Note that this is the same placeholder name we used in the outcome above if the rating was already included in the URL.The second column is a text label that displays the value for each row of data.
The
data:
can be an array of properties, like:When there is only a list of values, like:
Then Cerb automatically expands this for you to:
This is a convenient shortcut for our survey, which doesn't need any additional properties for a rating.
await/comment:
After the user provides a rating of 0-10 (or the rating was included in the URL), we want to prompt them for an optional comment:
This displays another form with a textarea element (multiple lines). We change the label dynamically with automation scripting to reflect their sentiment.
If the rating was 9 or 10 (promoter), the label is: "What do you like most about Cerb?"
If the rating was 7 or 8 (neutral), the label is: "What can we do to improve Cerb?"
If the rating was 6 or lower (detractor), the label is: "What was missing or disappointing with Cerb?"
We save the comment in a placeholder named
prompt_comment
.record.upsert/nps:
Now that we have an email address, rating, and comment, we're ready to create an NPS Survey record to store the result.
In some situations, the same respondent may be able to provide multiple responses.
If you recall, one of our requirements was that we only want to create one survey response per email address per 30 day period. So we can't just create records for every response.
Instead, we can use a
record.upsert:
command to conditionally create or update a record.This is probably the most impressive command in this example, given how much functionality it packs into a few lines of KATA:
The
record.upsert
command has inputs for therecord_type
,record_query
, andfields
to set. We've set it up to output the affected record dictionary in thenew_nps
placeholder.The
record_type
is a record type alias.The
record_query
looks for matching responses by the ID of the respondent's email address within the past 30 days.An upsert query must match exactly zero (create) or one (update) records of that type. Any other result is an error. You can include
limit:1
in the query to force no more than one result when there are multiple matches. In this case, thesort:
field determines which record is returned. We're usingsort:-created
, which returns duplicate survey responses in descending chronological (most recent first) order.Now if we have an NPS survey response from the same email address in the past 30 days, we update its rating and comment instead of creating a new one. Otherwise we create a new one.
This allows us to track NPS ratings per customer (or segment/cohort) over time with a granularity of 30 days. Based on your needs, you could change this to 90 days and send out NPS surveys quarterly, etc. You have full control over it.
The
fields:
are pretty straightforward.Each custom record has a required
name:
field. Using automation scripting, we're summarizing the respondent email address, rating (out of 10), and optional truncated comment preview (first 64 characters, ending in...
if longer).When we created the custom record we set up URIs for the custom fields (
email
,rating
,cohort
,comment
). We use those field names to quickly set those values from the survey placeholders.If there's an error creating the record, the
on_error:
event will run, and we exit the interaction automation with an error message to be logged. In practice this should very rarely ever happen.await/done:
Once the survey is complete and we've created a record for the response, we reply to the user with a "Thanks for your feedback!" message.
This is just another
await
form, with asay
element.In this case, we also provide an explicit
submit:
element and disable the continue/reset options. By default a submit button is always added for us as the last element in a form.That's it! The complete automation is below.
Finished script
Policy
Automations have a policy that determines their privileges. We recommend using the "principle of least privilege" and only enabling the minimal number of commands and options required to function.
In this example, we're using the
record.upsert:
command. That's a shortcut for therecord:create:
andrecord:update:
commands, which need to be specified individually because they have different options. We also userecord.search
to look up the email address. We only allow these four commands.On the Policy tab of the automation, we can use:
For each command, we deny permission if the record is not of the expected type. We could also be more specific by checking the values of other options. For instance, you could limit the automation to only being able to create or update specific field names on a record, or only setting specific values on those fields.
Each policy rule can have multiple (optionally named)
deny
orallow
conditions. The firsttrue
condition is used. So you can have multipledeny
tests that are ignored and a finalallow: yes
condition as a catchall.By default, all commands are denied in an automation.
Testing interactions in the automation simulator
You can test interactions entirely in the automation editor using "simulate" mode in the Run tab. This provides a step-based debugger where you can see the full input and output dictionaries.
This will help you uncover any errors before publishing the automation.
Let's use the
customer@cerb.example
email with the hash fragment from above as inputs:Click the run button:
In the "Output:" panel on the right, you should see the automation results with an
__exit
state ofawait
and the rating form in the__return
placeholder.You can simulate filling in a form by using the "Copy to input" button from the output:
Add a placeholder for
prompt_nps
to the input from the awaitingform:
Then run the simulator again to continue to the comment form. Use the left arrow icon to copy the output to the input.
Add a placeholder for
prompt_comment
to the input from the awaiting form:Run the simulator a final time and you should see "Thanks for your feedback!".
The simulated automation actually created a record. You can add actions in the on_simulate: event for any command to run alternative commands during simulation.
Viewing NPS survey records
After using the simulator above you should have your first NPS survey result.
Navigate to Search -> NPS Surveys:
Portal
Creating a portal for website interactions
Now let's set up a portal so we can send our survey to real customers.
Navigate to Search -> Portals -> (+).
surveys
Click the Save Changes button.
Configuring interactions in the portal
Click the "Surveys" link in the alert above the worklist to view the portal's profile:
Select the Configure tab.
On each portal or website, you're free to use any interaction names you want. For instance,
help
,signup
, etc.We'll be using events KATA to select which automation should respond to an interaction using your custom names.
Interactions can be opened on any website page from the URL, or they can be started by clicking on links and buttons.
Click the (+) button in the editor and select Automation in the menu.
Select the
example.website.survey.nps
automation we created above.If you click on the "tags" icon in the editor's toolbar it will show the available placeholders.
The
interaction
placeholder will contain the name of the interaction. Theinteraction_params
placeholder contains any parameters included with the interaction. For our purposes, the interaction could benps
and the params would have keys likeemail
andrating
.It's up to us to map the arbitrary parameters we're given to the inputs of the automation. The extra step of events KATA makes our automations far more reusable, because callers don't need to be aware of specific names for automations or their inputs. We can even generate values for the inputs right in the KATA.
We only want our automation to respond to the
nps
interaction, so we disable it otherwise.We pass any matching interaction parameters straight through to the automation inputs.
Click the Save Changes button.
Testing interactions in the built-in portal
Switch to the Deploy tab on the portal's profile.
Click the Built-in URL to view the portal within Cerb.
This is going to look like a blank page at first because we haven't specified an interaction.
Modify the URL path to:
/portal/surveys/nps?email=customer@cerb.example&s=invalid
We're intentionally providing the wrong signature. You should see the message we set up in
stopWithError
:Now change the URL to:
/portal/surveys/nps?email=customer@cerb.example&s=qbISkLjv
With the right signature in the link you should now see the survey linked to the
customer@cerb.example
email address. You can pick a different rating and comment to verify that it updates the record we created from the simulator, rather than creating a new one.cerb10_website_survey_nps.mov
Generating survey links
We're almost ready to send survey links to customers. First, we need a way to generate signed links for any email address.
Let's create a reusable automation function for generating links.
Navigate back to Search -> Automations -> (+).
example.website.survey.nps.link
automation.function
Paste the following automation:
Replace
https://cerb.example/
above with the URL to your Cerb installation.You should also change the HMAC secret from
a1b2c3d4
, but be sure to do the same thing in the NPS survey automation.You can now provide any email address as input and get a signed survey URL as output:
If you paste that URL into your browser it will start the survey for that email address. You can send that link directly to clients.
This automation doesn't require a policy because it only translates input to output using scripting with no commands.
Click the Save Changes button.
This automation function can be used from any other automation to get a signed link for an email. We'll use it to automate gathering NPS responses shortly.
Displaying interactions on any website
We just displayed the NPS survey interaction on a portal. We can also display interactions on our official website.
Paste the following
<script>
tag in the footer of your website (ideally above the</body>
).Again, replace
https://cerb.example/
above with your own Cerb base URL.You can now add a
data-cerb-interaction
attribute to any element on your web page (e.g. link, image, button). Its value should be the name of the interaction to start. This will start the interaction when clicked.You can include URL-encoded parameters in a
data-cerb-interaction-params
attribute on the same element. These are available to events KATA and can be passed to the automation.For instance:
You can also append an anchor tag to any URL on your website and open an interaction automatically. This also works in the portal.
Append the following anchor to the URL of any page on your website:
The interaction should start on top of that page:
You can also automatically display a floating icon in the bottom right of your website that starts an interaction by using the
data-cerb-badge-interaction
attribute on the<script>
tag above. The value should be the name of an interaction to start; likemenu
orhelp
.Reporting
You calculate your NPS score with
(% of Promoters) - (% of Detractors)
. The range will be from-100
(worst) to100
(best).Navigate to Setup -> Developers -> Data Query Tester.
You can use the following data query to tally NPS survey respondents by cohort (Promoter, Neutral, Detractor) by month over the past 12 months:
You could also report on the cohort tally over any time period. In this case, the past 30 days:
We can use this data query to calculate the NPS score in a reusable automation function. This would let us display the score on dashboard widgets, etc.
Let's create another automation.
Navigate to Search -> Automations -> (+)
Paste the following script:
Use this automation policy:
When you run the automation your calculated NPS score (past 30 days) is in the
__return:nps:
key of the output.Automatically sending NPS surveys
We want to automatically send an NPS survey every 90 days to contacts with specific criteria.
Create a custom fieldset for survey timing
We're going to use a custom fieldset on contact records to keep track of the schedule and opt-out status.
Navigate to Search -> Custom Fieldsets -> (+).
Click the Save Changes button.
Open the card for the fieldset using the link in the yellow alert above the worklist.
Click the (0) Fields button.
Click the (+) icon in the gray bar above the worklist to add two new fields:
Navigate to Search -> Contacts and run the query:
fieldset:!(name:NPS)
The
!
in the query is a negation operator. These are contacts without the NPS fieldset.We can now add the 'NPS' fieldset to contact records to include them in automatic surveys.
In the worklist, you can run this query to find contacts with the fieldset:
fieldset:(name:NPS)
Create an automation for sending surveys
To automatically send surveys to contacts we need an automation timer.
Navigate to Search -> Automations -> (+).
example.website.survey.nps.scheduler
automation.timer
Paste the following KATA:
This automation:
mail.transactional
draft to queue a new outgoing message that doesn't create a ticket. This approach will send email in the background to not slow down the automation, and it will retry failed messages in an observable way.In the Policy tab, paste the following policy:
This allows the data query, the creation of draft records, the updating of one field (
nps_lastSentAt
) on contact records, and the invocation of the function for generating signed survey links.Now let's set up an automation timer record to actually run this automation for us.
Navigate to Search -> Automation Timers -> (+).
now
Paste this repeat pattern for hourly (the 0th minute of every hour, every day):
Paste this events KATA:
As you've seen a few times now, automation timers also use events KATA to select an automation.
If the automation exits in the
await
state with a futureuntil@date:
timestamp, the timer will reschedule itself to run again. This uses a continuation, so any placeholders you set will be preserved between executions.If the automation exits in another state, a repeating timer will be rescheduled, and a non-repeating tier will be disabled.
Conclusion
You now know how to:
Beta Was this translation helpful? Give feedback.
All reactions