API reference menu
Getting started
OverviewAuthentication and rate limitExamplesSending
Send a notificationPriority and repeatLinks, images and buttonsSourcesScheduled sendsWebhook templatesReceipts and replies
Receipts and callbacksRepliesTeams
Teams, roles and endpointsChannels and escalationWebhooks and emailAlert email and timelineTeam formsPlans, audit log and transferCompany sign‑in and SCIMThe web consoleAccount
Identity and moving phonesDevices, keys and historyThe Upgrade PassCompatibility
ntfy's publish APIProwl's APIAppendix
Endpoint indexErrors and status codesLimitsPriorities and fieldsGlossaryTeam 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 same404.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,featureandquestion(no launch list). - Priority per kind decides how the alert arrives;
bug: 2repeats until someone takes it. - Claiming.
POST …/submissions/{id}/claimtakes it: one person, once, in a single statement. Two members at the same moment get one200and one409 already_claimednaming 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_days30 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/ |
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. Putdata-target="#id"on the tag to draw it into another element. - Behind a button (
popup_script): the same line withdata-mode="popup"shows a button that opens the form over your page.data-button="Ask us"sets the button's words;data-titlethe 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: coloursaccent,text,background,field,borderanderror(#rrggbb), andradius(0 to 24),spacing(4 to 40) andfont_size(12 to 22) in pixels, andfont(system,serif,monoorinherit, which uses your page's font). They become CSS custom properties (--nlf-accentand 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,esorrofor the form's built‑in words (the button, the thank-you and every error), orautofor the visitor's browser language.?lang=deon 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,stepandremember.{label},{min}and{max}are filled in, and{n}and{total}instep. 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 layernowlark, and yours comes after it outside any layer, so your rule wins without!important. Every part has a class startingnlf-(.nlf,.nlf-field,.nlf-label,.nlf-err,.nlf-send,.nlf-sent,.nlf-open,.nlf-dialog).@importand<are refused. -
remember:trueoffers 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", |
"Pro" |
yes |
{"field":"plan", |
"Free" |
no |
{"field":"plan", |
"Pro plan" |
no |
{"field":"plan", |
"pro" |
yes |
{"field":"plan", |
null |
no |
{"field":"plan", |
null |
yes |
{"field":"rooms", |
["Bathroom", |
yes |
{"field":"rooms", |
["Bathroom"] |
no |
{"field":"sso", |
true |
yes |
{"field":"sso", |
false |
yes |
{"field":"sso", |
null |
no |
{"field":"company", |
"Birch Joinery" |
yes |
{"field":"company", |
null |
yes |
{"field":"seats", |
12 |
yes |
{"field":"newsletter", |
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.
Consent records#
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 itssuccess_url, applies. A rule reads any of the form's fields, orkind,name,email,messageand the seven names in Where each message came from.opisis,is_notorcontains; capitals and spacing are ignored, soSales,salesandSALESall match, and a multi-select matches when any of its choices does. Each rule ends with amessageor aredirect. Up to 20 rules; a message is at most 1,000 characters. A thank-you rule takes onevalue, whereshow_iftakes a list. Put the most specific rule first. - One ending: a form with a
success_urltakes nomessage(422with problemmessage_and_redirect); send"clear_success_url":truein 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), likesuccess_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
422with"error":"invalid_thank_you"and aproblem:unknown_field,bad_op,outcome_missing,bad_redirect,too_long,too_many_rules,unknown_keyormessage_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, ornull(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.fieldis the field'sname, as in the form'sfields, not its label.actions, 1 to 5 of them:{"do":"push","channel":"chn_…"}notifies one of the team's channels (ids fromGET /v1/teams/{id}/channels), and{"do":"push","person":"acct_…"}one person on the team (themember_idfromGET /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,archivedorspam.{"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(defaultfalse):truelets 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(defaulttrue):falsepauses a rule without losing it.throttle(defaultnull, 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 |
|---|---|---|
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,, 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",: 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", |
|
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, ornull(the default) for every channel of the team.- What a condition can test:
source,titleandmessage(text, as in the table above;messageis the notifybody),priority(a number, -2 to 2, as sent) andtags(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 testsbusiness_hoursitself. - From a webhook template,
sourceis the tool (Grafana,Alertmanager,Uptime Kuma,GitHub …),titleis what the phone shows (for an alert group,[FIRING] alertname),messagethe 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.escalatedwebhook with the rule'sidandname.
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:/ |
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:/, or a Workflows address (https:/ or https:/) |
The channel's Workflows: "Post to a channel when a webhook request is received", or an Incoming Webhook connector |
discord |
https:/ |
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@everyonearrive as plain text. - A
webhookgets the facts as JSON (below), signed like a destination's: the answer toPOST …/connectionsincludes itssecret(whsec_…) once, and every post carriesX-Nowlark-Signature,X-Nowlark-Event: rule.matchedandX-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. A404or410means the webhook was deleted on the service's side and is not retried. Awebhookkeeps itsX-Nowlark-Deliveryacross 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_reasoninGET …/connections) until an admin switches it back on withPOST …/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 / |
admin |
{"connections":[…],, never with their addresses |
POST / |
admin |
{"kind",. 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 / |
admin |
409 connection_in_use, naming the rules, while a rule posts to it |
POST / |
admin |
Posts a test message now: {"ok":true,, or false with what the service answered ("HTTP 404"). Up to 10 an hour |
POST / |
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 persource, 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 ofMondaytoSunday, andtime,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, ornullfor 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 orkeep_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 / |
member |
{"hours":{…} or null,: the team's hours, and what a rule would read right now |
PUT / |
admin |
{"time_zone":"America/. 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 / |
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 / |
member |
{"rules":[…],, 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 / |
admin |
Add one at the end. Answers the rule, with its id (rul_…) and position |
GET, PATCH, DELETE / |
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 / |
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 / |
member on the form's channel |
What the rules would do with a message already received, sending nothing |
POST / |
admin, or member on the channel |
What the alert rules would do with an alert arriving at the channel, sending nothing (example below) |
POST / |
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
- In the Nowlark console, open the form and turn on the switch on its Google Analytics and Tag Manager card.
- 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.
- 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.
- Press Download for Tag Manager on that card. In Tag Manager, open your site's container, then Admin › Import container, and choose that file.
- 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.
- 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.
- 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
- In the Nowlark console, open the form and turn on the switch on its Google Analytics and Tag Manager card.
- 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.
- 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
- 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.
- 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.
- 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.