Responses & webhooks
Everything recipients send back — poll votes, form submissions, ratings, spins — is collected per project and flows back to you two ways: a per-recipient responses view in the dashboard, and an optional webhook that pushes each interaction to your own endpoint the moment it arrives.
Per-recipient responses in the dashboard
Open Dashboard → your email → Responses. Every interaction the email has collected is grouped by recipient and joined with the project's data source, so each group shows who answered — their email address and the other fields from their row — alongside what they did: which block, which action, the submitted values, and when.
Attribution works through the same signed tokens that protect live view links: when an email is personalized from a data source, each recipient's links carry a token bound to their row, and interactions arriving with a valid token are attributed to that row. Interactions that arrive without a token (for example a poll vote from an email sent without personalization) still count — they're listed in a separate Anonymous group.
The raw event list is paginated (newest first): once you've loaded the latest page, a Load more action fetches older events. Everything below — rollups, histograms, the funnel and trend — isn't affected by that pagination; it's precomputed and always covers the project's full history.
Orders, fulfillment and refunds
Any project with a product block gets an Orders table on its Responses page — one row per checkout, with the buyer, amount, status and placement time.
- The moment a payment clears, the buyer is emailed automatically: a digital product's delivery content, or a confirmation that a physical order is on its way to fulfillment.
- For physical orders, click Mark fulfilled once you've shipped it — MailInApp doesn't handle shipping itself, but it does email the buyer a shipped notice the moment you flip the switch.
- Click Refund on any paid order to reverse the charge through your connected Stripe account; the buyer is emailed a refund confirmation. The same email goes out automatically if a sale clears right as the last unit sells out — MailInApp refunds the buyer rather than silently over-selling.
- Contact buyer opens a support ticket prefilled with that order's context — the quickest way to ask a buyer something directly.
Purpose-driven headline metric
If a project has a purpose set — survey, promotion, newsletter or event — the Responses page leads with the one number that purpose cares about most: response rate for surveys, recipients engaged for promotions, and opens tracked for newsletters and events. Transactional projects and projects with no purpose set skip straight to the funnel and rollups below. Change or clear the purpose anytime from the dropdown next to the page title — it only affects which number is featured, never what gets recorded.
CSAT, CES and NPS rollups
Every rating block gets a rollup above the recipient list: response count, average, and a histogram (the distribution behind the average — a 4.1 average hides whether it's all 4s or a 5s-and-1s split). NPS-style blocks show promoter/passive/detractor counts and the −100…100 score instead of a plain average. A response distribution breakdown does the same for poll blocks, by option.
An engagement funnel (Sent → Opened (approx.) → Responded) sits above the rollups, and a per-block response trend chart (day or week buckets) shows the average over time — the value of a recurring survey is the trendline, not one snapshot; it only renders once a block has at least two buckets of data.
Survey question rollups
Every form block gets a per-question rollup, the same way rating and poll blocks do: a bar chart of option counts for multiple choice/checkboxes/linear scale/star rating questions, and a response count plus a handful of sample answers for free-text questions (short text, long text, email, number, phone, date). Multi-page surveys roll up identically to single-page ones — a submission only counts once every page has been completed, so an abandoned survey never shows up as a partial response.
These poll, rating and survey-question results aren't a dead end — any of them can be plotted directly inside a future email by binding a chart block to "campaign response," no manual re-entry required. See Binding charts to real data.
Homework & gradebook
Every assignment block with at least one completion or submission gets a stat card (completions, late completions, submissions) above a Gradebook table — one row per student, one column per assignment, showing "Done" or the submitted answer in green, or in red if it arrived after the block's due date. Assignments with no responses yet don't clutter the table. Like every other rollup, it's precomputed and covers a project's full history, and both CSV exports include an assignment column per block.
Click heatmap
Below the rollups, a Click heatmap ranks every plain link-bearing block — buttons, linked images, social icons — by click count, with a bar length and intensity scaled to relative volume. See how clicks are tracked for what does and doesn't count.
Scroll-depth funnel
A scroll-depth funnel shows what share of live-view visitors reached each 25/50/75/100% milestone of the page, so you can tell whether people are bailing early or reading to the end. It only reflects visits to the hosted live view, not the mailed email itself — see Scroll depth for why.
Subject-line A/B testing
Sending with more than one subject-line variant (see Send) adds a variant comparison to the Responses page: open rate and response rate side by side for each variant, with the current leader flagged. Every recipient is assigned a variant deterministically from their email address, so re-sends and retries never reshuffle who saw which subject — and results are cumulative for the project, covering every A/B send it's ever run, not just the most recent one.
Breaking down by a field
Use Break down by to bucket the same rollups by any field from your data source — average CSAT per agent, NPS per plan, and so on. Recipients whose row was deleted or shrunk since sending bucket under (unknown); if a free-text field produces more than 20 distinct values, the smallest buckets fold into a trailing Other group so the view doesn't explode.
CSV export
Download CSV on the Responses page exports a wide, one-row-per-recipient file: every data-source field, first-open time, and one column per interactive block (headed by its question), with follow-up answers in their own <question> — follow-up column. A second raw events export gives one row per event — recipient, block, action, value, timestamp — for analysts who want the long format instead.
Survey lifecycle
A project can have a close date and/or a response cap (maxResponses). Once either is reached, new interaction events are rejected — the live view shows a closed notice instead of interactive blocks, though static content still displays — and the Responses page shows whether the survey is currently closed. Nothing is filtered retroactively: responses already recorded stay in your data.
In-app low-score alerts
Beyond the webhook's lowScore flag (below), a project can list up to a handful of email addresses to notify directly — no Zapier/Make step required. Whenever a rating response crosses its block's configured threshold, MailInApp emails those addresses (through your own SMTP settings) with the question, the score, the recipient's identity where known, and a link straight to the Responses page. If no SMTP relay is configured the alert is skipped silently — the webhook still fires. Alerts are capped per project per hour so a burst of low scores can't flood your relay.
Rebuilding aggregates
Rollups, the funnel and trend are served from a precomputed per-project aggregate, updated as events arrive — so they stay fast regardless of how much history a project has. If a project shows unexpectedly low or zero totals, it likely collected responses before this aggregate existed for it; click Rebuild aggregates on its Responses page once to replay its full event history into the aggregate. New projects never need this.
Webhooks
If you'd rather have the data land in your own systems — a CRM, a spreadsheet, an automation tool — configure a webhook on the same Responses page: enter an HTTPS URL and click Enable.
Two things happen:
- You're shown a signing secret (
whsec_…) — copy it immediately, it is displayed only this once. It's stored server-side and masked everywhere afterwards, like all credentials in MailInApp. - From then on, every interaction is POSTed to your URL as JSON, right after it's recorded.
You can rotate the secret (a new one is minted and shown once) or remove the webhook at any time.
Payload
{
"type": "interaction.received",
"projectId": "abc123",
"event": {
"campaignId": "abc123",
"blockId": "poll-1",
"blockType": "poll",
"action": "vote",
"value": { "option": "Blue" },
"recipient": "row:3",
"projectId": "abc123",
"receivedAt": 1752480000000
},
"recipient": {
"key": "row:3",
"row": { "email": "[email protected]", "first_name": "Ada" }
},
"lowScore": false
}
event.valueis the collected data itself — the chosen poll option, the form's field values, the star count.recipientisnullfor anonymous interactions. For attributed ones,keyis the recipient's row index in your data source ("row:3"= fourth row).recipient.row— the recipient's full data row — is included for hosted data sources. For API-type sources we don't call your endpoint on every interaction; join on the row index on your side.lowScoreistruewhen the event is arateaction on a rating block whose value falls at or below that block's configured alert threshold — the signal a Zapier/Make automation (or your own in-app alert) filters on to page a support manager. It's omitted entirely on non-rating events, or when the block has no threshold configured.
Verifying signatures
Every delivery is signed so your endpoint can confirm it really came from MailInApp. Two headers are sent:
| Header | Contents |
| --- | --- |
| X-MailInApp-Timestamp | When the delivery was signed, in epoch milliseconds |
| X-MailInApp-Signature | v1= followed by hex HMAC-SHA256(secret, timestamp + "." + rawBody) |
Compute the expected signature from the raw request body (before any JSON parsing) and compare with a constant-time comparison. Rejecting stale timestamps blocks replayed deliveries:
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidDelivery(headers, rawBody, secret) {
const timestamp = headers["x-mailinapp-timestamp"];
const given = Buffer.from(headers["x-mailinapp-signature"] ?? "");
const expected = Buffer.from(
"v1=" +
createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex"),
);
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return false;
}
// Reject deliveries signed more than 5 minutes ago (replay protection).
return Math.abs(Date.now() - Number(timestamp)) < 5 * 60 * 1000;
}
Delivery semantics
- First attempt is immediate, then retried automatically. Deliveries time out after 5 seconds; anything short of a
2xxresponse (a timeout, a connection failure, or an error status) is treated as a failure. The event is always stored in MailInApp first, so a missed delivery loses nothing — treat the webhook as a real-time signal and the Responses view as the source of truth. - Automatic retries with backoff. A failed delivery is retried at increasing intervals — roughly 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours — against your webhook's current URL and secret, so a rotated secret or updated URL is picked up automatically. If every retry still fails, the delivery stops retrying on its own, but it's never discarded.
- Manual redeliver. Any delivery that's still failing (retrying or exhausted) shows up under Failed deliveries on the Responses page, with the reason for the last failure and a Redeliver button — useful right after you've fixed whatever was broken on your end, instead of waiting for the next scheduled retry.
- Never in the recipient's way. Deliveries happen after the recipient's interaction is acknowledged; a slow or broken endpoint can't delay or fail their vote or submission.
- Respond fast. Return any
2xxquickly and do heavy processing asynchronously.
Heads-up on trust: the interaction endpoints are public by necessity (an inbox can't authenticate), so anonymous events are unauthenticated by design. Attributed events are protected by signed recipient tokens. Verify the delivery signature, and treat
event.valueas user input.