docs

Sources

Every door a Submission can come through, and what each one records. Whichever door it used, a Submission is the same object: checked against the Fields, with provenance and a timeline.

A Source is a way a Submission enters besides the HTTP POST. There is one ingest behind all of them: the Form's defaults and locked values, the Field contract, the first status word, and the webhooks and Gate that run after. A door decides only who may knock and what the provenance says.

Provenance

Every Submission records three things about how it arrived, whichever door it used:

ColumnWhat it holds
sourceThe door: api, page, mcp, import, slack or email.
senderWho sent it, in the door's own terms, or empty when the door cannot say.
sourceRefA link back to the original: a page path, a file row, a Slack permalink, a Message-ID.

They show on the Submission's page, go out in every webhook body, and come back from the MCP tools.

Submissions stored before provenance existed were filled in from the Form's kind. A survey Form's rows say page with the Form's page path. A feedback Form's rows say api. Neither had a sender.

The doors

DoorsourcesendersourceRefCan reply
HTTP ingestapi——no
The survey pagepage—/f/<handle>no
MCP submit_submissionmcptoken:<prefix>—no
CSV importimportthe importer's email<file>#row<n>no
Slackslackthe Slack user idthe message's permalinkin its thread
Inbound emailemailthe From addressthe Message-IDin its thread

The survey page

The public page posts to its own route, POST /api/v1/f/:apiKey/page. It has the same contract and limits as /api/v1/f/:apiKey; the route is what records page, not a header. It is provenance, not trust: anyone can open the page, and anyone can call its route.

With bot protection on, the route also asks for what only a rendered page has: the page's signed render time and an empty honeypot. Those travel as request headers, never in the body, so a Submission's payload is only what was said.

MCP

POST /mcp offers every tool the token's scopes allow. A token with write also sees submit_submission, which takes a formId and a payload and checks it exactly as the API does. The sender is the token's prefix, never the token. MCP has its own rate limit per token, so an agent never spends a Form's public ingest allowance. /mcp/readonly keeps offering only the read tools.

CSV import

Submissions → Import CSV takes up to 200 rows. The header is Field keys, one Submission per row, and each row goes through the same ingest as a POST: it gets the first status word and fires the Form's webhooks. A row that fails the Fields is refused on its own and the rest still import.

The columns the Studio's own export writes are understood, so an export can come back. Its id column makes the import safe to repeat: a row whose id was imported before returns the Submission it made the first time.

This is not the CSV on Import Forms, which carries Fields and makes a new Form.

Slack

A feedback Form can take its Submissions from a Slack channel. Every top-level message a person posts there becomes a Submission. The text is written to the Field you pick, and the Form's other Fields take their defaults. Thread replies are conversation, not Submissions, and so are edits, joins and anything a bot posts.

  1. On the Workspace's Integrations page, Connect Slack. This installs the bot once for the Slack workspace. One Slack workspace belongs to one Noonmark Workspace.
  2. In Slack, invite the bot to the channel: /invite @Noonmark.
  3. On the Form's Integrations → Slack, pick the workspace, paste the channel id (the channel's name → About → Channel ID) and the Field, and switch it on.
WhatHow
The answer to SlackA 200 as soon as the signature checks out. Everything else runs after, so Slack never waits more than its three seconds or retries.
A message sent twiceThe Submission's sourceKey is slack:<channel>:<ts>, so Slack's retries make one Submission.
LimitsNo per-IP ingest limit: every event comes from Slack, and the signature decides.
A message the Fields refuseThe bot says why in the message's thread, and nothing is stored.
A status changeThe bot answers in the message's thread with the new word and the note.

A message that arrives while the Form is paused is ignored.

Disconnect revokes the bot's token and switches off every Form's Slack Source that used it. Post to Slack posts through its own webhook and is not affected. Submissions keep their provenance; their threads stop being answered.

Setting up the Slack app

The deployment needs one Slack app, with these environment variables:

VariableFrom the Slack app's settings
SLACK_CLIENT_IDBasic Information → App Credentials
SLACK_CLIENT_SECRETBasic Information → App Credentials
SLACK_SIGNING_SECRETBasic Information → App Credentials
  • OAuth & Permissions: the redirect URL is https://studio.noonmark.dev/integrations/slack/callback (or, while the Studio has no host of its own, https://<your host>/studio/integrations/slack/callback), and the bot scopes are channels:history, groups:history and chat:write.
  • Event Subscriptions: the request URL is https://<your host>/api/v1/sources/slack/events, and the bot events are message.channels and message.groups.

The bot token is kept server-side on the Workspace's Connection and never rendered.

Inbound email

A feedback Form can have an address of its own, such as f-3a9c01b2e4d7@in.acme.com. Every message sent to it becomes a Submission. The body goes to the Field you pick, and the subject goes to another Field if you pick one. The address is minted the first time you switch it on on Integrations → Inbound email, and it stays the same from then on. Switching the Source off closes the address; switching it back on opens the same one.

WhatHow
What is storedThe body, the subject if a Field takes it, the sender's address and the Message-ID. Attachments are not kept.
A message delivered twiceThe sourceKey is email:<Message-ID>, so a redelivery makes one Submission.
A reply in the threadIf its In-Reply-To or References points at one of this Form's Submissions, it is conversation and not stored.
A message the Fields refuseDropped, and not answered. The From address of inbound mail can be forged, and a reply would be backscatter.
A status changeA reply from the Form's address with Re: <subject>, In-Reply-To and References, so the sender's mail client threads it.
LimitsNo per-IP ingest limit. The webhook's signature decides.

Setting it up

Resend receives the mail. Point the MX record of a domain you own at Resend's inbound, verify the same domain for sending, and add a webhook for email.received at https://<your host>/api/v1/sources/email. Resend's webhook carries only the envelope, so Noonmark then reads the body and headers from Resend's Receiving API before it answers. If that read or the store fails, the webhook answers 500 and Resend sends it again. The Message-ID makes the retry the same Submission.

VariableWhat it does
INBOUND_EMAIL_DOMAINThe domain the addresses are on, e.g. in.acme.com.
RESEND_WEBHOOK_SECRETThe webhook's signing secret (whsec_…). Webhooks are checked with it: svix-id, svix-timestamp, svix-signature.
RESEND_API_KEYReads received mail and sends the replies. The same key invitations use.

What did you think of this content?

On this page