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:
| Column | What it holds |
|---|---|
source | The door: api, page, mcp, import, slack or email. |
sender | Who sent it, in the door's own terms, or empty when the door cannot say. |
sourceRef | A 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
| Door | source | sender | sourceRef | Can reply |
|---|---|---|---|---|
| HTTP ingest | api | — | — | no |
| The survey page | page | — | /f/<handle> | no |
MCP submit_submission | mcp | token:<prefix> | — | no |
| CSV import | import | the importer's email | <file>#row<n> | no |
| Slack | slack | the Slack user id | the message's permalink | in its thread |
| Inbound email | email | the From address | the Message-ID | in 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.
- 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.
- In Slack, invite the bot to the channel:
/invite @Noonmark. - 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.
| What | How |
|---|---|
| The answer to Slack | A 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 twice | The Submission's sourceKey is slack:<channel>:<ts>, so Slack's retries make one Submission. |
| Limits | No per-IP ingest limit: every event comes from Slack, and the signature decides. |
| A message the Fields refuse | The bot says why in the message's thread, and nothing is stored. |
| A status change | The 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:
| Variable | From the Slack app's settings |
|---|---|
SLACK_CLIENT_ID | Basic Information → App Credentials |
SLACK_CLIENT_SECRET | Basic Information → App Credentials |
SLACK_SIGNING_SECRET | Basic 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 arechannels:history,groups:historyandchat:write. - Event Subscriptions: the request URL is
https://<your host>/api/v1/sources/slack/events, and the bot events aremessage.channelsandmessage.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.
| What | How |
|---|---|
| What is stored | The body, the subject if a Field takes it, the sender's address and the Message-ID. Attachments are not kept. |
| A message delivered twice | The sourceKey is email:<Message-ID>, so a redelivery makes one Submission. |
| A reply in the thread | If its In-Reply-To or References points at one of this Form's Submissions, it is conversation and not stored. |
| A message the Fields refuse | Dropped, and not answered. The From address of inbound mail can be forged, and a reply would be backscatter. |
| A status change | A reply from the Form's address with Re: <subject>, In-Reply-To and References, so the sender's mail client threads it. |
| Limits | No 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.
| Variable | What it does |
|---|---|
INBOUND_EMAIL_DOMAIN | The domain the addresses are on, e.g. in.acme.com. |
RESEND_WEBHOOK_SECRET | The webhook's signing secret (whsec_…). Webhooks are checked with it: svix-id, svix-timestamp, svix-signature. |
RESEND_API_KEY | Reads received mail and sends the replies. The same key invitations use. |
What did you think of this content?