Skip to content
Nowlark
API reference menu

Team forms

A team can run its own website forms. A visitor posts to https://nowlark.com/v1/forms/{slug} (the same public endpoint the three fixed sites use) and the submission becomes an alert on one of the team's channels, so everything a channel does applies: members' phones, escalation when nobody opens it, receipts, and its webhooks and email.

curl -X POST https://nowlark.com/v1/teams/team_…/forms -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{
    "slug":"kitchens", "label":"Kitchens",
    "origins":["https://kitchens.yourcompany.com"],
    "channel_id":"chn_…", "priorities":{"inquiry":1,"bug":2},
    "retention_days":365, "success_url":"https://kitchens.yourcompany.com/thanks"}'

To start from a ready-made form, send "template" with one of contact, contact-sales, demo, newsletter, event, job, feedback, support, quote, waitlist or partner: the form gets that template's fields and its theme, and you can change both afterwards. If the request also sends fields, they replace the template's fields; the template's theme still applies. An unknown name is a 400 unknown_template. See any template as a visitor would at https://nowlark.com/f/templates/{name}; the console's New form shows them all.

The response, and each form in GET /v1/teams/{id}/forms, carries a copy-paste HTML form (snippet; the console shows it on the form's page with a Copy button) that works with no JavaScript: it posts as a normal browser form, and with a success_url the visitor is redirected there (?ref=<id> is added). A form with its own fields (below) gets a snippet that asks for exactly those fields, with the same required marks, options, limits and patterns our server checks, so a pasted copy sends what the form accepts. A script posting JSON gets JSON back, never a redirect. For stronger spam filtering the snippet also loads /v1/forms/{slug}/fill.js, which adds a hidden nl_fill field so messages sent by automated scripts are filed as spam. It sets no cookies and stores nothing about the visitor, and it runs in the browser on every load, so a cached page is fine. On a form you build yourself, add the same <script> tag inside the <form>; a page or a JSON post without it still works and simply skips that check. Without JavaScript, a submission with a missing or invalid field shows a short page that lists what to fix, with a Back to the form link. The link goes to the page in the hidden nl_page field when it is set: fill.js and the form script set it for you, and on any other page you can put the page's full address in it yourself. When it is empty, the link uses the page the browser reports it came from, which many browsers shorten to your home page. Either way it only ever points to a page on one of the form's origins (below); any other address shows no link. nl_page is used only for this link and is never stored with the submission, shown in the console or sent in a notification. Spam is kept, not deleted: Not spam in the console moves a message back and trusts its sender.

  • Slug: 3 to 40 lowercase letters, digits and hyphens; global (one namespace with the three fixed sites); a few words are reserved. Origins: up to 10 exact https://host[:port] values, no wildcards, no paths. A browser post from any other origin is refused. A suspended or unknown form is the same 404. success_url: must be a page on one of the form's own origins, so a form cannot be turned into an open redirect.
  • Limits: 3 forms on Team, any number on Team Plus; each form accepts 500 submissions a day; 50 MB of attachments per team; the usual per-sender and per-install rate limits, spam filing and honeypot as on the fixed sites. A teams' kinds are inquiry, bug, feature and question (no launch list).
  • Priority per kind decides how the alert arrives; bug: 2 repeats until someone takes it.
  • Claiming. POST …/submissions/{id}/claim takes it: one person, once, in a single statement. Two members at the same moment get one 200 and one 409 already_claimed naming who has it. Claiming settles the alert for everyone: no more repeats, no escalation, whatever the channel's ack policy, even when the claimer has no copy (an admin not on the channel). The receipt and the webhook name the claimer by the name they chose for the team.
  • Who sees it. Admins see every form; anyone else only forms announcing to a channel they are on, and a form they may not see is a 404, the same as one that does not exist. One team's admin can never reach another team's form, whatever slug they name.
  • Export (admin) is CSV (default) or ?format=json. A customer wrote every cell, and a spreadsheet runs a cell that starts with =, +, -, @, tab or return as a formula, so such cells are prefixed with a quote.
  • Erasing. Deleting a submission, its form, its team or the owner's account (or letting retention expire it, retention_days 30 to 730) also deletes the members' copies of the alert, its webhook and email outbox rows, and its thread: the customer's words are not left in anyone's feed or queue. Not recalled: what has already been delivered to a webhook receiver or a mailbox.
  • Misuse. A form that is misused can be taken offline by us; nothing is deleted and the team can still read what it has.
  • The web console has an inbox page for each form (/org/team/{id}/forms/{slug}) with claim buttons and an export link.

Your own fields#

A form can ask for the fields you choose. Send fields when you create or change a form (PATCH with the whole list replaces it; "fields": [] goes back to the standard fields) and every list of forms returns it. The console's form page has the same editor: add fields from a palette of every type, drag them into order (or move them with the arrow keys), group them into sections, edit each one's question in place and its rules, help text and choices beside it, undo a change, and watch a live preview as you go.

curl -X PATCH https://nowlark.com/v1/teams/team_…/forms/kitchens -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{"fields":[
    {"name":"email","type":"email","label":"Email","required":true},
    {"name":"budget","type":"number","label":"Budget","min":1000,"max":50000},
    {"name":"rooms","type":"multi-select","label":"Rooms","options":["Kitchen","Bathroom","Utility"]},
    {"name":"phone","type":"phone","label":"Phone","format":"international"},
    {"name":"order","type":"text","label":"Order number","pattern":"[A-Z]{2}-[0-9]{6}",
     "pattern_message":"Your order number looks like KT-123456"},
    {"name":"plan","type":"file","label":"Floor plan","accept":["image","pdf"],"max_bytes":2000000},
    {"name":"terms","type":"consent","label":"I agree to be contacted about this quote","required":true}]}'
Type An answer is Rules it can carry
text, textarea, hidden
text
max_length, pattern with pattern_message; hidden also value_from, value_key, default
email, url, phone
an address, an http(s) link, a phone number
the same; phone also format: any, international (starts with +) or us
number
a number
min, max
date
a date like 2026-12-31
min, max (dates)
select, radio
one of options
options
multi-select
a list of options (a plain HTML form sends the name once per choice)
options
checkbox, consent
ticked or not (true, or on from a browser)
required means it must be ticked; consent also text (the exact words shown beside the box, up to 2,000 characters) and version
file
a file posted as multipart/form-data
accept (image, pdf, zip, text), max_bytes

Every field has a name (lowercase letters, digits and underscores, unique in the form), a label, required, and optional help. text, textarea, email, phone, url and number may also carry a placeholder, an example shown inside the empty box. Any field but hidden may carry a group, a section heading of up to 80 characters: fields next to each other with the same group are drawn as one section under that heading (an HTML <fieldset> with its <legend>, in the hosted page and the pasted snippet alike). A section's fields must be next to each other and in one step; otherwise the save is refused with a 422 and group_split or group_across_steps. A field may be called name, email or message, and then also fills that column of the inbox, the alert and the export. With your own fields, kind is optional and defaults to inquiry, and message is optional too. A form holds up to 50 fields and 3 file fields. A pattern must match the whole answer; to keep every check fast, it may not repeat a group that itself repeats or holds alternatives, and an answer to a field with a pattern is at most 256 characters.

The schema is checked when you save it: a mistake is a 422 naming the field and the problem, for example {"error":"invalid_fields","field":"budget","problem":"min_over_max","message":"budget: min is more than max"}. Other problems include duplicate_name, options_missing, bad_pattern, pattern_too_complex, bad_limit and too_many_fields.

To check a list without saving it, POST …/forms/{slug}/fields/preview (admin) with {"fields":[…]}: it runs the same checks and returns {"html": …}, the form exactly as the hosted page would draw it, or the same 422.

Every submission is checked on our server against the form's fields, so a wrong answer never reaches your inbox. A refusal is a 422 naming the field and the rule; for a pattern, the message is the one you wrote:

{"error":"invalid_field","field":"budget","rule":"max","message":"Budget must be at most 50000",
 "errors":[{"field":"budget","rule":"max","message":"Budget must be at most 50000"}]}

field and rule name the first problem; errors lists every one, in the form's order. The rules are required, type, max_length, pattern, format, min, max, option, file_type and file_size. Anything the form does not name is dropped. A file field's bytes are checked for what they are, not what they are called, and count toward the same 5 MB per message as other attachments.

What was answered comes back on each submission as answers, in the form's order (a file as its name, type, size and attachment_id, fetched from …/attachments/{id}), and the export adds one column per field after the standard ones. Changing the fields later never rewrites what was already sent.

Hidden fields that fill themselves#

A hidden field can take its value from the visitor's page, so you can pass a campaign code, a partner or a plan without writing any script. Set value_from:

value_from The value is
page (the default)
whatever your page puts in the field
url
the URL parameter named in value_key, from this page or the first page of the visit
cookie
the cookie named in value_key, and no other; never name a sign‑in or session cookie
referrer
the site the visitor came from
landing_page
the address of the first page of the visit
{"name":"partner","type":"hidden","label":"Partner","value_from":"url","value_key":"pid","default":"direct"}

default is used when nothing is found, and also when the visitor's browser runs no scripts, because we fill it in on our server. The default must pass the field's own rules. The page is read by the <script … fill.js> line of your embed code, so keep it.

Where each message came from#

Add UTM tags to your ad and email links (the ?utm_source=…&utm_campaign=… part; Google Ads and most email tools can add them for you), and each message says which one brought the sender: utm_source, utm_medium, utm_campaign, utm_term and utm_content, the site they came from (referrer), and the first page they visited (landing_page).

The fill.js line reads these on the first page of the visit that has your embed code and keeps them in your site's own tab storage, so a form three clicks in still says how the visit began. A visit lasts until the tab is closed, and a link with new UTM tags starts a new one, so the newer link gets the credit. A click from one of your own pages is not a referrer. None of this reaches us until someone sends the form.

They come back on each submission as tracking (null when none was recorded, for example when the browser runs no scripts), show beside each message on the form's console page, and fill the last seven columns of the export. A tag over 200 characters, or a referrer or landing page that is not a valid web address, is left out; it never causes a message to be refused. These seven names, and any name starting with nl_, are reserved for this, so your own fields use other names.

Its own page, one line of script, and its look#

Put a team form on the web in four ways: a page of its own, one line in your page, behind a button, or the plain HTML snippet above. Each form in the API returns the first three as page_url, script and popup_script, and the console's form page shows all four with a Copy button.

  • Its own page, https://nowlark.com/f/{slug}: the form alone, ready to link from an email, an ad or a QR code. It asks search engines not to list it (noindex), so it never competes with your site.
  • One line in your page (script): <script src="https://nowlark.com/v1/forms/{slug}/embed.js" async></script> shows the form right where the line is, with your fields, checks each answer as the visitor types, and sends it without leaving the page. Put data-target="#id" on the tag to draw it into another element.
  • Behind a button (popup_script): the same line with data-mode="popup" shows a button that opens the form over your page. data-button="Ask us" sets the button's words; data-title the heading.

All three fill your hidden fields and record where each visit began (see "Hidden fields that fill themselves" and "Where each message came from" above). Every field has a label, each error is written next to its field and linked to it for screen readers, focus moves to the first field to fix, everything works from the keyboard, and the popup returns focus to its button when it closes. A site with its own Content-Security-Policy allows https://nowlark.com in script-src, style-src and connect-src.

Its look is a look object you send with PATCH /v1/teams/{id}/forms/{slug} (the console's form page has the same editor, with a live preview). Every key is optional, and "look": {} goes back to the built‑in look:

curl -X PATCH https://nowlark.com/v1/teams/team_…/forms/kitchens -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{"look":{
    "theme":{"accent":"#0f766e","radius":4,"font":"inherit"},
    "language":"auto",
    "messages":{"de":{"send":"Anfrage senden"}},
    "css":".nlf-send { text-transform: uppercase; }"}}'
  • theme: colours accent, text, background, field, border and error (#rrggbb), and radius (0 to 24), spacing (4 to 40) and font_size (12 to 22) in pixels, and font (system, serif, mono or inherit, which uses your page's font). They become CSS custom properties (--nlf-accent and so on). A colour pair below the WCAG contrast minimum is refused with a 422 naming it (text on its background below 4.5:1, a border or the accent below 3:1), and the button's text is set to white or near-black, whichever reads better on your accent.
  • language: en, de, fr, es or ro for the form's built‑in words (the button, the thank-you and every error), or auto for the visitor's browser language. ?lang=de on the page or script address chooses one for a single link.
  • messages: your own wording per language, keyed by the built‑in name, up to 200 characters each: send, sending, sent, fix, failed, limited, required, ticked, email, url, phone, phone_international, phone_us, number, date, min, max, date_min, date_max, option, too_long, pattern, file_type, file_size, optional, choose, open, close, name_label, email_label, message_label, back, next, step and remember. {label}, {min} and {max} are filled in, and {n} and {total} in step. An error sentence is shown under its field's label, on the form and on the page a browser without JavaScript gets when an answer is refused, so the built‑in ones leave the label out ("Enter 50 or less."); that page is in the form's language too.
  • css: your own CSS, up to 20,000 characters. Every built‑in rule is inside the CSS layer nowlark, and yours comes after it outside any layer, so your rule wins without !important. Every part has a class starting nlf- (.nlf, .nlf-field, .nlf-label, .nlf-err, .nlf-send, .nlf-sent, .nlf-open, .nlf-dialog). @import and < are refused.

  • remember: true offers the visitor a box, "Remember my answers on this device". When they tick it, their answers are saved in that browser after the form is sent and filled in on their next visit, until they untick it, which forgets them at once. The box sits beside Send on the last step, and on every step when this browser filled the form in, so a visitor on a shared computer can clear it straight away. The saved copy stays in the browser: Nowlark receives only what is submitted, as with any form, and a file or a consent box is never saved.

A bad value is a 422 such as {"error":"invalid_look","field":"theme.text","message":"…"}. POST /v1/teams/{id}/forms/{slug}/look/preview with {"look":{…}} runs the same check and returns the stylesheet it would make, {"css":"…"}, without saving. Anyone may fetch the sheet and the script the page loads: GET /v1/forms/{slug}/form.css and GET /v1/forms/{slug}/embed.js.

Conditional fields, multi-step forms and prefill#

A field can appear only when an earlier answer calls for it. Give it show_if, naming a field above it, an op and, for is and is_not, the values to look for:

{"name":"seats","type":"number","label":"Seats","required":true,
 "show_if":{"field":"plan","op":"is","values":["Pro","Enterprise"]}}

A rule matches a whole answer, so Pro never matches Pro plan. Capitals and runs of spaces do not count, so pro matches Pro, the same as in thank-you rules and routing rules. A choice is its option, a multi-select matches when any of its choices does, and text is the answer with its outer spaces trimmed. For is, a ticked checkbox answers yes and an unticked one no; for filled and empty, an unticked checkbox counts as not answered. Every type but file can be tested for filled and empty; what is and is_not match in each:

Field type Matches
select, radio, multi-select
one of its options
checkbox, consent
yes or no
text, textarea, email, phone, url, date, hidden
any text
number, file
nothing: neither can be tested this way

A field that is hidden counts as not answered, so a field shown by a hidden field is hidden too:

show_if The other field's answer Shown
{"field":"plan","op":"is","values":["Pro","Enterprise"]}
"Pro"
yes
{"field":"plan","op":"is","values":["Pro","Enterprise"]}
"Free"
no
{"field":"plan","op":"is","values":["Pro","Enterprise"]}
"Pro plan"
no
{"field":"plan","op":"is","values":["Pro","Enterprise"]}
"pro"
yes
{"field":"plan","op":"is","values":["Pro","Enterprise"]}
null
no
{"field":"plan","op":"is_not","values":["Free"]}
null
yes
{"field":"rooms","op":"is","values":["Kitchen"]}
["Bathroom","Kitchen"]
yes
{"field":"rooms","op":"is","values":["Kitchen"]}
["Bathroom"]
no
{"field":"sso","op":"is","values":["yes"]}
true
yes
{"field":"sso","op":"is","values":["no"]}
false
yes
{"field":"sso","op":"is","values":["no"]}
null
no
{"field":"company","op":"filled"}
"Birch Joinery"
yes
{"field":"company","op":"empty"}
null
yes
{"field":"seats","op":"filled"}
12
yes
{"field":"newsletter","op":"filled"}
false
no

Nowlark checks the same rules on every submission, including ones posted straight to the API: a field its rule hides is never required, and whatever was sent for it is dropped and saved as no answer. The form's own page and the embed script (embed.js) hide it as the visitor answers, and leave it out when they send. A hidden field cannot carry a rule. A rule that names a field below it, an unknown option or a test the field cannot have is a 422 with problem bad_rule or bad_rule_value.

Steps. Set "step": 2 on the first field of the second step; the fields below it stay on step 2 until one sets "step": 3. Steps go 1, 2, 3 in the list's order, up to 10. The form's page and script then show one step at a time, with "Step 2 of 3" and a progress bar, Back and Next; Next checks only that step's answers, and the form is sent once, from the last step. A step whose every field is hidden by its rules is skipped and not counted. With JavaScript off, the form still works and shows every step on one page. A 422 names the problem: bad_step (a step is not 1 to 10), step_order (a field's step is lower than the one above it) or step_gap (a step number is skipped).

Prefill. Link to a form with answers in the address and they are filled in: https://nowlark.com/f/kitchens?plan=Pro&rooms=Kitchen&rooms=Bathroom. The embed script reads the address of the page it is on. A parameter is a field's name; a multi-select takes several, a checkbox takes yes to tick it. A file and a consent box are never filled in, because agreeing is the visitor's own act. Analytics and server logs record addresses, so prefill what you would put in a link anyway (a plan, a campaign), not an email address or a name. With look.remember (above), what a returning visitor sent last time is filled in first, and the address wins over it.

Keep a record of what each sender agreed to, in the words you set, and when. A consent field records, with every message, whether the sender ticked the box, the words beside it, their version, and the time we received the message (UTC). The record holds those four values and nothing else about the sender.

Put the words in text (the label is used when there is none) and name the version in version, for example 2026-10. Leave it out and a short code made from the words stands in, so changing the words changes the version.

{"name":"terms","type":"consent","label":"I agree to be contacted","required":true,
 "text":"I agree that Kitchens Ltd may contact me about this quote.","version":"2026-10"}

Each submission carries the record as consents, null for a form without a consent field:

"consents":{"terms":{"given":true,"text":"I agree that Kitchens Ltd may contact me about this quote.",
                     "version":"2026-10","at":"2026-10-15T09:41:07+00:00"}}

No console screen or API call can change a consent record. Change the words later and earlier messages keep the words their senders agreed to. The form's console page shows the record beside each message, and the export adds three columns per consent field, {name}.consent_text, {name}.consent_version and {name}.consent_at (consents in ?format=json), including for a field you have since removed. Erase a message by its reference (DELETE /v1/teams/{id}/forms/{slug}/submissions/{submission_id}) and its consent record goes with it.

We record the words saved in the field's text. Use the same words on your page, and every record matches what your visitor read.

After someone sends a form#

Thank each sender in your words, or send them to a page on your site, and show different people a different thank-you depending on what they filled in or which campaign brought them. Set it in the console on the form's page, or with thank_you (and success_url for a page) when you create or change the form:

curl -X PATCH https://nowlark.com/v1/teams/team_…/forms/kitchens -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{"thank_you":{
    "message":"Thanks, we will reply within a day.",
    "rules":[
      {"field":"topic","op":"is","value":"Sales","redirect":"https://kitchens.yourcompany.com/sales-thanks"},
      {"field":"utm_campaign","op":"contains","value":"spring","message":"Your spring discount is noted."}]}}'
  • Rules are tried top to bottom and the first that matches is used; when none matches, the form's own message, or its success_url, applies. A rule reads any of the form's fields, or kind, name, email, message and the seven names in Where each message came from. op is is, is_not or contains; capitals and spacing are ignored, so Sales, sales and SALES all match, and a multi-select matches when any of its choices does. Each rule ends with a message or a redirect. Up to 20 rules; a message is at most 1,000 characters. A thank-you rule takes one value, where show_if takes a list. Put the most specific rule first.
  • One ending: a form with a success_url takes no message (422 with problem message_and_redirect); send "clear_success_url":true in the same change to switch.
  • Redirects stay on your own sites, so nobody can use your form to send visitors somewhere else: each must be a page on one of the websites the form is set up for (its origins), like success_url; a page on any other site is refused. The message's reference is added as ?ref=<id>; it is the message's id and says nothing about the sender.
  • Later changes cannot break a thank-you rule: removing a field a rule reads, or narrowing the origins below a rule's redirect, is refused. A mistake is a 422 with "error":"invalid_thank_you" and a problem: unknown_field, bad_op, outcome_missing, bad_redirect, too_long, too_many_rules, unknown_key or message_and_redirect. "thank_you":{} removes it.

What the sender gets. A browser posting the form without JavaScript is sent to the page (303), or shown a page with your message, its reference and a link back to the form. A script posting the form gets JSON carrying the same outcome, so it can show it without loading a page:

{"id":"frm_…","received":true,"thank_you":{"message":"Thanks, we will reply within a day."}}
{"id":"frm_…","received":true,"thank_you":{"redirect":"https://kitchens.yourcompany.com/sales-thanks?ref=frm_…"}}

Show thank_you.message as plain text in place of the form, or go to thank_you.redirect as given. A form with no thank-you set answers {"id","received"}.

Routing rules#

Send each message to the right people, at the right priority, based on what the sender wrote. Rules are checked top to bottom each time one of the team's forms is sent, or an alert arrives at one of its channels, and the first that matches decides. A message that matches no rule is delivered as if the team had no rules: to the form's (or the alert's) own channel, at its own priority, with that channel's escalation.

curl -X POST https://nowlark.com/v1/teams/team_…/rules -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{
    "name":"Big kitchen jobs ring sales",
    "form":"kitchens",
    "conditions":[[{"field":"budget","op":"over","value":5000},{"field":"topic","op":"is","value":"Kitchens"}],
                  [{"field":"urgent","op":"yes"}]],
    "actions":[{"do":"push","channel":"chn_…"},{"do":"priority","value":2},{"do":"escalate","value":false}]}'
  • name (required): 1 to 80 characters, shown wherever the rule is listed and in the dry run.
  • trigger: what the rule runs on. "form" (the default) for a form's messages, "alert" for alerts arriving at a channel, "not_taken" for an alert or message nobody has opened in time (below), and "schedule" for a digest at set times (below).
  • form: the form's slug, or null (the default) for every form of the team. Form and schedule rules only.
  • conditions: a list of groups. Every condition in a group must hold (AND), and any one group is enough (OR). [] (the default) matches every message. Up to 10 conditions a rule. field is the field's name, as in the form's fields, not its label.
  • actions, 1 to 5 of them:
  • {"do":"push","channel":"chn_…"} notifies one of the team's channels (ids from GET /v1/teams/{id}/channels), and {"do":"push","person":"acct_…"} one person on the team (the member_id from GET /v1/teams/{id}). A rule with a push notifies only who it names. A rule without one notifies the form's own channel, with the rule's priority and escalation.
  • {"do":"priority","value":2} sends it at that priority, -2 to 2, in place of the form's priority for its kind.
  • {"do":"escalate","value":false} notifies the channel's first tier only. true, the default, runs the channel's escalation as usual.
  • {"do":"file"} notifies nobody: the message is saved in the form's inbox with the others, and no alert is sent. It cannot be combined with a push, a priority or an escalation; it can with the actions below.
  • {"do":"post","connection":"con_…"} posts the message to one of the team's connections: a Slack, Microsoft Teams or Discord channel, or a webhook of your own. A rule can post to several.
  • {"do":"assign","person":"acct_…"} assigns the message to someone on the team. Assigning does not take it: the alert still goes out as the rule says, until someone takes it.
  • {"do":"tag","value":"lead"} tags the message (1 to 40 characters; a rule can add several tags).
  • {"do":"status","value":"archived"} sets its status: read, archived or spam.
  • {"do":"reply","subject":"Thanks, {name}","message":"…","reply_to":"sales@yourcompany.com"} emails the person who sent the form, in your words. See Email the sender.
  • Assign, tag, status and reply act on form messages. An alert is assigned by being taken, and routed with push, post and priority.
  • keep_checking (default false): true lets later matching rules notify, post and tag too. Priority and escalation are taken from the first matching rule that sets them; later rules cannot change them.
  • enabled (default true): false pauses a rule without losing it.
  • throttle (default null, every time): {"minutes":10,"per":"rule"} lets the rule act at most once in that many minutes (1 to 1440). See How often.

However many channels and people a rule notifies, the message is one alert: when one person takes it, it is handled for everyone. Only the first channel's escalation runs, and a rule that notifies only people runs none. Each channel notified also sends to its own webhooks and email recipients. A deleted channel or a person who has left the team is skipped, and when that leaves a matching rule nobody to notify, the message goes to the form's own channel instead, so it is never dropped. Rules apply to messages received after they are saved.

What a condition can test, by the field's type. Capitals and runs of spaces do not count in text, and a multi-select matches when any of its choices does:

Field op value
text, textarea, email, phone, url, hidden, and name, email, message and the tracking names
is, is_not, contains, starts, ends
text
in
a list of texts, any of them
select, radio, multi-select, and kind
is, is_not
one of its options
in
a list of its options
number
is, over, under
a number
between
[lowest, highest], both included
date
is, before, after
YYYY-MM-DD
between
[first, last], both included
checkbox, consent
yes, no
none
any field
filled, empty
none

A number compares as a number, so 10000 is over 5000. A number condition does not match an answer that is not a number, such as about 6k, and the next rule is checked; a number field on the form keeps answers numeric.

Every rule, on any trigger, can also test when it is checked, in the team's time zone (business hours):

Field op value
time_of_day
before, after
HH:MM, 24-hour, that minute not included: after 17:00 starts at 17:01 and ends at midnight
between
["22:00","06:00"]: from the first minute, up to but not including the second; it may run past midnight
weekday
is, is_not
Monday to Sunday
in
a list, such as ["Saturday","Sunday"]
business_hours
yes, no
none; the team must have set its business hours first (422 needs_business_hours)

A form rule reads the time the message was sent; an alert rule the time the alert arrived; a not-taken rule the time its wait runs out. Times are the team's wall clock, daylight saving included. weekday is the calendar day, so at 02:00 on a Tuesday it is Tuesday even inside hours that started on Monday night.

Rules on alerts#

"trigger":"alert" routes what arrives at a channel, however it was sent: POST /v1/notify with the channel's key, the webhook templates, ntfy and Prowl, and a scheduled or recurring channel send (checked when it goes out, against the rules saved then).

curl -X POST https://nowlark.com/v1/teams/team_…/rules -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{
    "name":"Database pages go to the DBAs out of hours",
    "trigger":"alert", "channel":"chn_alerts…",
    "conditions":[[{"field":"title","op":"contains","value":"postgres"},{"field":"business_hours","op":"no"}],
                  [{"field":"tags","op":"is","value":"db"},{"field":"business_hours","op":"no"}]],
    "actions":[{"do":"push","channel":"chn_dba…"},{"do":"priority","value":2}]}'
  • channel: the channel the alerts arrive at, or null (the default) for every channel of the team.
  • What a condition can test: source, title and message (text, as in the table above; message is the notify body), priority (a number, -2 to 2, as sent) and tags (text; it matches when any one of the alert's tags does), and the time fields above. A condition on time sits in a group like any other, so each group that should only match out of hours tests business_hours itself.
  • From a webhook template, source is the tool (Grafana, Alertmanager, Uptime Kuma, GitHub …), title is what the phone shows (for an alert group, [FIRING] alertname), message the summary, and there are no tags. The channel dry run (below) shows what a rule would make of a given alert.
  • Actions are the form rule's. A rule without a push notifies the alert's own channel. {"do":"file"} keeps the alert on its channel, where its receipt finds it, and notifies nobody.
  • The alert stays one alert, on the first channel the rule notifies, and the key that sent it can still read its receipt. Only that channel's escalation runs, and a rule that notifies only people runs none.
  • An alert is routed once, by the rules of the channel it arrived at. The channels a rule notifies do not route it again, so rules cannot pass an alert round in a loop.

Not taken in time#

"trigger":"not_taken" with "after_minutes" (1 to 1440; escalation's after is in seconds) acts when nobody has opened an alert or form message (read, taken, answered or claimed it) that long after it was sent. channel is the channel the alert ended up on, after any alert rule moved it (the dry run's thread_channel), or null for every channel. Its conditions read the alert as delivered, priority included:

curl -X POST https://nowlark.com/v1/teams/team_…/rules -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' -d '{
    "name":"Nobody on it after 15 minutes: wake the lead",
    "trigger":"not_taken", "after_minutes":15, "channel":"chn_dba…",
    "conditions":[[{"field":"priority","op":"over","value":0}]],
    "actions":[{"do":"push","person":"acct_lead…"},{"do":"priority","value":2}]}'
  • Its actions are pushes, to channels or people, posts to connections, and optionally a priority. It needs at least one push or post. The same alert goes to everyone it names: someone who already has it is alerted again on the one they hold, someone who does not is sent it. When one of them takes it, it is taken for everyone.
  • Each not-taken rule is its own timer, so a 15-minute rule and a 60-minute rule both act, in turn. Each acts at most once on an alert, and only on alerts sent after the rule was saved. If its conditions do not hold when the time comes, it does not act on that alert later.
  • An alert a rule filed, which nobody was ever sent, is never acted on.
  • Escalation or a not-taken rule? A channel's escalation is its fixed ladder, for every alert on it. A not-taken rule can also test what the alert says and the time, and reach people who are not on the channel. Both stop once someone opens the alert, and when both are set, both run.
  • Not-taken rules are checked every 15 seconds. The channels it notifies get a thread.escalated webhook with the rule's id and name.

Post to Slack, Microsoft Teams, Discord or a webhook#

A rule posts to the team's connections. Add each one once, with the address the service gives you. In the console an admin does it on the team page, under Connections, and picks it in a rule's Post list. Through the API, name its id in any rule's {"do":"post"}:

curl -X POST https://nowlark.com/v1/teams/team_…/connections -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' \
  -d '{"kind":"slack","name":"#sales","url":"https://hooks.slack.com/services/T…/B…/…"}'
{"id":"con_…","kind":"slack","name":"#sales","host":"hooks.slack.com","disabled_at":null,"disabled_reason":null,
 "created_at":1760054400.0}
kind url Where to get it
slack
https://hooks.slack.com/services/…
At api.slack.com/apps: Create New App, From scratch, pick the workspace; Incoming Webhooks, turn it on, Add New Webhook to Workspace, pick the channel, copy the address
teams
https://….webhook.office.com/webhookb2/…, or a Workflows address (https://….logic.azure.com/workflows/… or https://….powerplatform.com/…/workflows/…)
The channel's Workflows: "Post to a channel when a webhook request is received", or an Incoming Webhook connector
discord
https://discord.com/api/webhooks/…
Channel settings, Integrations, Webhooks
webhook
any public https address
Your own server

name is your label for it, shown in rule lists and dry runs; the channel a post lands in is set by the address. Then check it arrives with POST /v1/teams/{id}/connections/{connection_id}/test, and name it in a rule. This one posts every Kitchens message to Slack and tags it:

{"name":"Kitchens to Slack","form":"kitchens","actions":[{"do":"post","connection":"con_…"},{"do":"tag","value":"lead"}]}

A rule with no push still notifies the form's own channel; to send to Slack only, add {"do":"file"} to it.

  • The address is a password. Anyone who has a Slack, Teams or Discord webhook address can post to that channel, so after you save it no answer shows it again, only its host. To change it, add the new address, point the rules at the new connection, then delete the old one.
  • Slack, Teams and Discord get a formatted message: the title, what the sender wrote, the form's answers (or the alert's source, priority, channel and tags), the rule's name, and a link to the message in the console's inbox or to the alert's own url. Everything the sender filled in, name and email included, shows in that channel, so post only where its members may see it. Nobody is pinged by what a sender typed: @channel, @here, <!here> and @everyone arrive as plain text.
  • A webhook gets the facts as JSON (below), signed like a destination's: the answer to POST …/connections includes its secret (whsec_…) once, and every post carries X-Nowlark-Signature, X-Nowlark-Event: rule.matched and X-Nowlark-Delivery. If you lose the secret, add the address again as a new connection and move the rules to it.
  • One post per message and connection, even when several matching rules name it.
  • Retried when it fails. A post that gets no answer within 5 seconds, or any answer but a 2xx, is tried again after 30 seconds, 5 minutes, 30 minutes, 2 hours and 12 hours. A 404 or 410 means the webhook was deleted on the service's side and is not retried. A webhook keeps its X-Nowlark-Delivery across retries, so your server can skip one it already has.
  • Switched off after 20 failures in a row. When 20 posts in a row have failed every try, the connection is switched off (disabled_at, disabled_reason in GET …/connections) until an admin switches it back on with POST …/connections/{connection_id}/enable. While it is off, rules skip it and everything else they do still happens; posts it missed are not sent later. A test post never counts toward the 20.
{"event":"rule.matched","trigger":"form","at":1760054400.0,"rule":{"id":"rul_…","name":"Big kitchen jobs"},
 "team":{"id":"team_…","name":"Harbour Kitchens"},"form":{"slug":"kitchens","name":"Kitchens"},
 "submission":{"id":"frm_…","kind":"inquiry","title":"New inquiry from Pat","name":"Pat","email":"pat@yourcustomer.com",
   "message":"…","answers":{"budget":9000},"fields":[{"label":"Budget","value":"9000"}],"url":"https://nowlark.com/org/team/…"}}

An alert rule's post has "trigger":"alert", channel and alert (id, source, title, message, priority, tags, url) in place of form and submission; a not-taken rule's has "trigger":"not_taken" and waited_minutes. A schedule rule's has "trigger":"schedule" and digest (since, until, count, title, text, url, and submissions, each id, form, kind, name, at).

Method and path Who What
GET /v1/teams/{id}/connections
admin
{"connections":[…],"max":20}, never with their addresses
POST /v1/teams/{id}/connections
admin
{"kind","name","url"}. 422 bad_url for an address that is not that service's webhook, 409 already_exists for one already connected, 409 too_many_connections past 20 per team (any number of rules can share one)
DELETE /v1/teams/{id}/connections/{connection_id}
admin
409 connection_in_use, naming the rules, while a rule posts to it
POST /v1/teams/{id}/connections/{connection_id}/test
admin
Posts a test message now: {"ok":true,"error":null}, or false with what the service answered ("HTTP 404"). Up to 10 an hour
POST /v1/teams/{id}/connections/{connection_id}/enable
admin
Switch one back on after it was switched off

How often#

"throttle":{"minutes":30,"per":"…"} lets a rule act at most once in that many minutes:

  • "per":"rule": once for the rule, whoever sent the message.
  • "per":"sender" (form rules): once per sender's email address, so one customer sending five messages in a minute is one alert while everyone else's still come through.
  • "per":"source" (alert and not-taken rules): once per source, so a tool that fires the same alert over and over is one post, and a different tool is not held back.

When its throttle holds a rule back, the rule is skipped as if it had not matched: the next rule is checked, and with none, the message takes its own channel's path, so it still reaches someone. To silence repeats instead, add a rule below it with {"do":"file"}, which saves them to the inbox without notifying anyone. The dry run lists held rules in held. A not-taken rule is the one exception: held back, it skips that alert's extra push and post, and the alert stays open on its channel until someone takes it.

Email the sender#

A form rule can answer the person who sent the form, by email, as soon as it matches:

{"do":"reply","subject":"We have your message",
 "message":"Hi {name},\n\nThanks for writing to us. Someone from the team will reply soon.",
 "reply_to":"sales@yourcompany.com"}
  • subject: 1 to 120 characters, on one line. message: 1 to 2,000 characters of plain text.
  • {name} in either becomes the sender's first name, or "there" when they gave none or what they typed in the name field does not read as a name (a link, an address, numbers).
  • reply_to: one of your team's addresses, where the sender's answer goes, so a person who replies reaches you.

The email says your team's name and why it was sent, and has a one-click link to stop email through Nowlark to that address. It holds only your words, never what was typed into the form, so nobody can use your form to send their own text to a stranger. Someone who writes again the same day gets no second email, and a team sends up to 200 a day, which protects its sending reputation; past that, messages still arrive and are not emailed back. A message Nowlark's spam check flags is never answered, and an address that has bounced, complained or used the stop link is never written to again. Only the first matching rule that replies sends an email, even with keep_checking. The dry run's would has reply: {"rule","subject","to","reply_to", "sends"}, or null. GET /v1/teams/{id}/rules answers "email_available"; when it is false, a rule that replies cannot be saved (422 email_unavailable).

A digest on a schedule#

"trigger":"schedule" sends one notification at set times listing the messages still waiting since its last run, such as every Monday at 9:00:

{"name":"Monday digest","trigger":"schedule","form":null,
 "schedule":{"days":["Monday"],"time":"09:00"},
 "actions":[{"do":"push","channel":"chn_…"},{"do":"post","connection":"con_…"}]}
  • schedule: days, one or more of Monday to Sunday, and time, HH:MM, in the team's time zone (business hours sets it; UTC until then). 9:00 stays 9:00 when the clocks change.
  • form: one form's messages, or null for every form of the team.
  • actions: a push to channels or people, a post to connections, or both, and a priority. A schedule rule has no conditions, throttle or keep_checking.

Each run covers the messages that arrived since the last run and are still waiting: still new, not assigned, not claimed and never opened. The notification lists the first 10 by time, form and name, with how many more, and links to the inbox. When nothing is waiting, nobody is notified. A digest is a summary, so it does not run the channel's escalation. A delayed run is sent within 6 hours or not at all, so you never get a stale list, and the next run then covers its messages too (up to 15 days back).

POST /v1/teams/{id}/rules/schedule/dry-run shows what a run would list now, sending nothing: {"rule":{…}} tries a rule you have not saved, {"rule_id":"rul_…"} a saved one, and at (epoch seconds) another moment. It answers {"would":{"since","due","next","time_zone","count","items":[{"id","form","slug","kind","name","at"}],"title","body", "sends"}}, where due is the run it describes and next the one after it.

Business hours#

Method and path Who What
GET /v1/teams/{id}/hours
member
{"hours":{…} or null,"now":{"time_of_day","weekday","business_hours"}}: the team's hours, and what a rule would read right now
PUT /v1/teams/{id}/hours
admin
{"time_zone":"America/New_York","days":["Monday",…,"Friday"],"start":"09:00","end":"17:30"}. time_zone is an IANA name and is required; days, start and end come together. Hours that end before they start (22:00 to 06:00) run overnight and count for the day they start on. 409 hours_in_use, naming the rules, for a time zone alone while a rule tests business_hours
DELETE /v1/teams/{id}/hours
admin
Back to UTC with no business hours. 409 hours_in_use, naming the rules, while any rule tests business_hours, time_of_day or weekday, since they would move to UTC

A team that has not set a time zone reads time_of_day and weekday in UTC.

Method and path Who What
GET /v1/teams/{id}/rules
member
{"rules":[…],"max":50}, in the order they are checked. Someone who is not an admin sees the rules on every form and on the forms whose channel they are on. Each rule in this list also has matched_week: how many messages it decided in the last 7 days (a dry run is not counted). A schedule rule also has next_run, when it next sends (epoch seconds); every other rule has null. email_available says whether a rule can email the sender
POST /v1/teams/{id}/rules
admin
Add one at the end. Answers the rule, with its id (rul_…) and position
GET, PATCH, DELETE /v1/teams/{id}/rules/{rule_id}
member reads, admin changes
PATCH changes only what it is sent, then checks the whole rule again; a rule that fails is left as it was. {"enabled":false} pauses one
PUT /v1/teams/{id}/rules/order
admin
{"ids":[…]}: every one of the team's rule ids once, rules on every form included, in the new order. A list that misses or repeats one is 409 stale_order (someone may have just added a rule): read the list again and resend
POST /v1/teams/{id}/forms/{slug}/submissions/{submission_id}/dry-run
member on the form's channel
What the rules would do with a message already received, sending nothing
POST /v1/teams/{id}/channels/{channel_id}/dry-run
admin, or member on the channel
What the alert rules would do with an alert arriving at the channel, sending nothing (example below)
POST /v1/teams/{id}/rules/schedule/dry-run
admin
What a schedule rule's digest would list now, sending nothing

Try a rule before it goes live. The dry run takes the id of a message from the form's inbox (GET …/forms/{slug}/submissions). With no body it uses the saved rules; {"rules":[…]}, the same objects POST …/rules takes, tries a list you have not saved in their place:

{"submission":"frm_…","would":{"rule":{"id":"rul_…","name":"Big kitchen jobs ring sales"},"rules":[…],
 "otherwise":false,"filed":false,"channels":[{"id":"chn_…","name":"Sales"}],"people":[],
 "thread_channel":{"id":"chn_…","name":"Sales"},"priority":2,"escalate":false,"sends":true}}

The channel dry run takes an alert as it would arrive, and at (epoch seconds, default now) to try another time of day. It answers for the alert rules; a not-taken rule acts later, so it is not part of the answer:

curl -X POST https://nowlark.com/v1/teams/team_…/channels/chn_alerts…/dry-run -H "Authorization: Bearer nla_…" \
  -H 'Content-Type: application/json' \
  -d '{"alert":{"source":"Grafana","title":"[FIRING] postgres replica lag","priority":1,"tags":[]},"at":1760054400}'

Each would also says what the rules hand off: posts ([{"id","name","kind"}]), assign ({"id","name"} or null), tags, status, and held, the rules that matched but that throttle holds back now.

Both dry runs answer the same would, and both take {"rules":[…]} to try a draft list. rule is the first rule that matches, and rules every rule that adds to the message (more than one only with keep_checking). otherwise is true, and rule is null, when no rule matches and the form's own channel is used. filed is true when a matching rule files it. thread_channel is the channel the alert belongs to, whose escalation runs, and sends says whether anyone would be notified.

A team has up to 50 rules (409 too_many_rules past that). A rule that cannot be saved is a 422, {"detail":{"error":…,"message":…}}, whose message names the part of the rule that is wrong: unknown_field, bad_op (an operator that field's type does not take), bad_value, bad_conditions (not a list of groups), too_many_conditions, bad_actions (none, or more than 5), bad_action (one action, or a combination such as file with a push), unknown_channel, unknown_person, unknown_connection, bad_throttle, bad_trigger, bad_schedule, email_unavailable, unknown_form, bad_name or unknown_key. A form cannot drop or retype a field one of its rules reads (422 rule_needs_field). Deleting a form permanently deletes its rules with it. Saving a rule never resends a message that was already delivered.

Two versions of a form#

Show about half a form's visitors a second version, B, and keep the one more people send. POST /v1/teams/{id}/forms/{slug}/test starts it, with B a copy of the form; change B's fields or look with PATCH …/test/b, checked as the form's own are (POST …/test/b/look/preview previews B's look). The form's own page and its one line of script pick each visitor's version from a one-way code of the test, their address and their browser, so a visitor who comes back on the same network and browser sees the same one, and nothing about them is kept to do it. Add ?v=a or ?v=b to the page's address to see one version. A form pasted as plain HTML always shows A and is not counted in the test. Changing either version starts the count again.

GET …/test answers {"running": false}, or each version's numbers since the count began and a read-out in words:

{"running":true,"started_at":1791676800,
 "versions":[{"variant":"a","views":412,"starts":96,"sent":51,"spam":2,"abandoned":45,"conversion":0.1238,"start_conversion":0.5312},
             {"variant":"b","views":405,"starts":121,"sent":78,"spam":1,"abandoned":43,"conversion":0.1926,"start_conversion":0.6446}],
 "readout":{"leader":"b","chance":0.9966,"sure":true,
            "said":"Version B gets more people to send. On the numbers so far, the chance it does better: 99 in 100. You can keep it now."}}

The read-out names a leader only once each version has 100 views and the two have 30 sends between them, and calls it sure at 95 in 100. POST …/test/keep {"variant":"a"|"b"} ends the test: B's fields and look become the form's, or A stays as it is. While no test runs, the three admin routes answer 409 no_test, and starting a second one answers 409 test_running.

Google Analytics and Tag Manager#

A form can tell the Google tag or Tag Manager already on your site when it is seen, started and sent, so its numbers sit in your own Google Analytics 4 beside the ads and campaigns that brought people. Because the events go through your own tag, your consent banner applies to them. It is off until an admin turns it on: PATCH /v1/teams/{id}/forms/{slug} with {"google":{"events":true}}. Nowlark never loads a Google script on your page; a page with neither tag sends nothing.

Event Sent when
form_view
The form comes into sight on the page (for a popup form, when the popup opens)
form_start
The visitor first answers any field
form_submit
The form is sent

Each carries form_id (the form's id), form_name (the form's name), form_destination (the Nowlark address the form posts to), and nothing else: never a field's value or anything a visitor typed.

On a page with Tag Manager each event is pushed to its data layer (dataLayer.push({event:"form_submit", form_id, ...})), for a Custom Event trigger and a GA4 Event tag to send. On a page with only the Google tag, the form calls gtag("event", "form_submit", {...}) itself. GET …/google/container.json (admins) is a Tag Manager container to import with Merge: a trigger and a GA4 Event tag per event, the three parameters as Data Layer Variables, and your measurement ID as a constant. It carries the measurement ID saved on the form and serves every form that reports to that ID, so import it once; importing it again with Overwrite changes nothing. A form drawn by the one-line script also fires a nowlark:sent event on the form element once a message is accepted, for your own code.

Count submissions from Nowlark's server (optional). Add the stream's measurement ID and a Measurement Protocol API secret: {"google":{"measurement_id":"G-…","api_secret":"…"}}. Nowlark's server then sends form_submit to GA4 for each message it accepts (never one filed as spam) from a visitor whose browser carries your Google Analytics cookie, with that visitor's GA client ID and session, and that browser leaves form_submit out so each send counts once. Google sets the cookie only where your consent banner allowed it, so a visitor without it is never sent from the server; their browser sends form_submit through your tag as before. Turning the setting off stops both at once. The secret is stored encrypted, with a key kept outside the database, and never shown again, not even to admins; "api_secret":"" removes it, and clearing the measurement ID removes both. GET …/google answers the setting ({"events":true,"measurement_id":"G-…","server":true}), the steps below and a prompt for an AI agent with this form's details in it.

Check the secret. GA4 accepts a wrong measurement ID or API secret without an error, so saving them cannot confirm they match. POST …/google/test (admin only) sends one form_submit marked debug_mode through the saved secret and answers {"sent":true,"message":"…","where_to_look":"…","if_missing":"…"}: where_to_look says where the event shows in GA4 (within a minute or two in GA4 › Admin › Data display › DebugView, when the two come from the same stream) and if_missing says what to check when it does not. If GA4's validation server reports a problem with the event, the answer is "sent":false with Google's description in message; without a saved secret it is 409 no_google_secret.

Not sure which your site has? If its pages load a container whose ID starts GTM-, follow the Tag Manager steps; otherwise follow the Google tag steps. Either way, turn off Form interactions in the stream's enhanced measurement: GA4 records form_start and form_submit itself for forms it can see, and would count each send twice.

With Google Tag Manager

  1. In the Nowlark console, open the form and turn on the switch on its Google Analytics and Tag Manager card.
  2. In GA4 › Admin › Data collection and modification › Data streams, open your web stream and copy its measurement ID (G-...) into Measurement ID on that card, then save.
  3. In the same web stream, click the gear beside Enhanced measurement, turn off Form interactions, leave Enhanced measurement itself on, and save. Each submission is then counted once, not twice. Form interactions covers your whole site, so this also stops GA4 counting your other forms: if you report on those, ask whoever built your site to tag them first.
  4. Press Download for Tag Manager on that card. In Tag Manager, open your site's container, then Admin › Import container, and choose that file.
  5. Choose your workspace (usually Default Workspace), choose Merge, then Overwrite conflicting tags, triggers and variables, and confirm. Every item in the file is named “Nowlark -⁠ …”, so only earlier Nowlark items are replaced and your own tags are untouched.
  6. Open Variables and check that Nowlark -⁠ GA4 measurement ID shows the measurement ID saved on that card. Once it matches, press Submit, then Publish. If it differs, download the file again and import it the same way. Do the same whenever you change the measurement ID.
  7. Submit the form once yourself (accept your own cookie banner first) and open GA4 › Reports › Realtime: form_view, form_start and form_submit appear within a minute or two.

With the Google tag (gtag.js) only

  1. In the Nowlark console, open the form and turn on the switch on its Google Analytics and Tag Manager card.
  2. In GA4 › Admin › Data collection and modification › Data streams, open your web stream, then click the gear beside Enhanced measurement, turn off Form interactions, leave Enhanced measurement itself on, and save. Each submission is then counted once, not twice. Form interactions covers your whole site, so this also stops GA4 counting your other forms: if you report on those, ask whoever built your site to tag them first.
  3. Submit the form once yourself (accept your own cookie banner first) and open GA4 › Reports › Realtime: form_view, form_start and form_submit appear within a minute or two.

Optional: count submissions from Nowlark's server

  1. In GA4 › Admin › Data collection and modification › Data streams, open your web stream, then Measurement Protocol API secrets (accept Google's terms if it asks), choose Create, name it (Nowlark, say), choose Create again and copy the Secret value.
  2. Paste it into API secret on that card. If Measurement ID is empty, copy in this web stream's measurement ID (G-...) too, then save. The two must come from the same web stream.
  3. After saving, press Send a test event and open GA4 › Admin › Data display › DebugView: a form_submit appears within a minute or two. The test may also show as one extra submission in your reports. Real submissions sent this way reach Realtime and your reports as usual.

Optional: see each form by name in your reports

In GA4 › Admin › Data display › Custom definitions, choose Create custom dimension: name Form name, scope Event, event parameter form_name. Repeat with Form ID and form_id. Names appear on these events from then on, not on earlier ones.