MacroReply documentation
Widget · email · API — one inbox, and a loop that closes
MacroReply collects what your customers tell you, helps your team act on it, and closes the loop when something is done about it.
Requests arrive three ways — a widget you paste into your site, an email address of your own, or the API. They land in one inbox with a type, a priority, an owner and a status, where your team answers them, adds notes customers never see, and turns bugs into GitHub issues. Closing that issue marks the request resolved and tells the person who reported it.
Around that: response-time targets with overdue alerts into Slack; a portal, under your own name and address, where customers sign in and see their requests, with an administrator per organisation who sees everything their company filed; answers drawn from your own help centre before a question ever becomes a ticket; and a view of what people keep asking for, ranked.
Where to start
- 01
Put the widget on your site
One script tag —Install, below. Feedback starts arriving immediately. - 02
Decide how you want to be reached
Add your email address as a second channel, and set response targets so anything overdue is visible. - 03
Connect where your team already works
Slack for triage, GitHub for bugs — each takes a few minutes. - 04
Give your customers a way in
Turn on the portal and send mail from your own address, so everything your customers see carries your name rather than ours.
Here is the path a single message takes, end to end:
Once your project has an address in Settings, it also has an email address — something like acme-k7f2q9@in.macroreply.com. Anything sent there becomes a request, exactly like a message through the widget.
The random part is deliberate. A guessable address is a spam target, and would let a stranger file requests into your inbox. It is fixed for the life of the project, so renaming the project never breaks a forwarding rule you set up months earlier.
Use your own address. Forward support@yourcompany.com to it. One rule in your mail provider, no DNS change, and if you ever turn the forward off your own inbox carries on as before.
Replies find their way back. Every email we send about a request has a reply address carrying that request's own identifier, so when someone presses Reply it lands on the same request rather than opening a new one. Mail that carries no identifier starts a new request — putting a message on the wrong request would bury one customer's problem inside another's.
Quoted history and signatures are trimmed so a long thread stays readable. Automatic mail is never answered: out-of-office replies, bounces, mailing lists and bulk mail are recognised and ignored, which is what stops two mail systems replying to each other forever.
If you already know who is on the page, tell the widget and the email field disappears — the message arrives attributed, and your reply reaches them without anyone typing an address. Set the global before or after the script tag; it is read fresh each time the panel opens, so single-page apps can set it once the user resolves.
attribute feedback to a signed-in user
window.MacroReplyUser = {
email: "ana@customer.com",
name: "Ana Rojas",
meta: { plan: "pro", accountId: "a_8813" }
};meta is yours to use — plan, tenant, app version, last route. It is stored with the message and shown in the inbox, which is usually the difference between a bug report you can act on and one you have to chase. For a plain HTML site, data-mr-email and data-mr-name on the script tag do the same job.
The launcher is configured per project in the app and fetched by the widget at load, so changes apply everywhere without touching the customer's site again.
| Setting | What it controls | Default |
|---|---|---|
| buttonColor | Launcher background | #7c3aed |
| buttonTextColor | Launcher label or icon | #ffffff |
| widgetPosition | bottom-right, or a mid-right edge tab | bottom-right |
| widgetAppearance | text label, or a chat icon | text |
| buttonLabel | Text on the launcher | Feedback |
| panelHeading | Heading inside the open panel | Send us feedback |
| placeholder | Message field placeholder | What's on your mind? |
| thanksText | Confirmation after sending | Thanks! |
The four copy fields cap at 80 characters and fall back to the default when cleared, so a blank field can never ship an unlabelled button.
Point the widget at your MacroPrimer workspace and it searches your help centre as someone types, offering matching articles before they send. Many questions never become tickets — which is the cheapest possible way to answer one.
Not switched on yet. This MacroReply instance is not connected to MacroPrimer, so the setting below has nothing to search.
Connecting your help centre
- 01
Find your MacroPrimer workspace name
It is the first part of your help centre address — foracme.help.macroprimer.comthe workspace isacme. - 02
Enter it under Widget
Settings → Widget, inAnswer from your help centre. Clearing the field turns it off again. - 03
Type a question into your own widget
Matching articles appear after a few characters. Nothing appears if your knowledge base has not been built yet, or if no article matches — silence is deliberate, since a confident wrong answer is worse than none.
Only public knowledge is used. Anything marked internal in MacroPrimer never leaves it, and a workspace with no knowledge base yet simply shows nothing.
A question with no answer is worth more than the answer. When the search finds nothing, the question is recorded as a documentation gap. Those accumulate into a ranked list of what your help centre fails to cover, measured by how many people hit it — which is the list worth writing from next.
Search runs on the exact words in your articles, so it never invents an answer. A question it cannot match returns nothing rather than a confident guess, and the widget behaves exactly as it does today.
Every message arrives with a type chosen by the sender, a status of new, and a priority of normal. Your team sets the rest.
| Field | Values | Set by |
|---|---|---|
| type | bug · feature · question · other | the sender, your team can correct it |
| status | new · open · waiting · done | your team |
| priority | low · normal · high · urgent | your team |
| assignee | any member of the project | your team |
The widget offers Bug, Idea and Question; other is what the API records when a message arrives without a recognised type. Correcting a type is recorded — the corrections are what make classification better over time, so they are kept rather than overwritten.
waiting means the ball is in the customer's court. It matters beyond tidiness: it is what stops the response-time clock, so time spent waiting on a reply you asked for is not counted against you.
Decide how a request is filed before anyone reads it. A rule watches for something — the type, how it arrived, words in the message, the sender's domain, the organisation they belong to — and sets a priority, a type, a status or an owner.
Rules run once, when the request arrives, and never again. Anything you change afterwards stays changed: a rule cannot reach back and undo a person's decision. Rules apply in order, and a later one wins where two set the same thing, so the list reads top to bottom like a sentence.
A rule with no conditions never matches — a half-built rule must not quietly apply to everything that arrives. And a rule can park a request or move it along, but never resolve it: closing something nobody has read is not triage, it is losing it.
If rules cannot be read for any reason, the request is stored anyway, unrouted. Losing a bug report to a broken rule would be a poor trade.
Set a target per priority in Settings — how long a request may wait for its first reply, and how long until it's resolved. Overdue requests are flagged in the inbox and collected in the Breaching queue, alongside Unassigned and Mine.
The clock stops while you're waiting on the customer. Time a request spends in waiting is subtracted from resolution time, so asking a question and waiting three days for an answer doesn't count against you. First-reply time has no such adjustment — a request can't be waiting on the customer before anyone has written to them.
Only a reply the customer can actually read starts the clock. An internal note is not a first response, and Reports shows median and p90 by assignee, priority and organisation.
Two things are deliberately not counted. With no targets set, nothing is marked overdue and reports show real times without judging them — rather than showing everything as on track. And a request closed without any reply is listed separately rather than counted as a fast response, because the customer never heard from you.
Response time says how fast you answered. It does not say whether anything came of it. Every request carries an outcome instead:
| Outcome | Means | Counts as resolved |
|---|---|---|
| Shipped | A linked issue closed and the person was told | yes |
| Answered | Resolved, and a reply had reached them | yes |
| Closed unanswered | Resolved without anything reaching them | no |
| Open | Still in flight | no |
An issue link alone is not shipped. An open issue is a promise, not an outcome — it counts only once the issue closes and the person who asked has been told.
The headline figure is the share that reached an outcome. Closing tickets moves your response time; only this moves that number, which is the point of measuring it. If it stays flat while volume grows, requests are being closed rather than answered.
The customer's own verdict
Once a request has been answered or resolved, the person who sent it is asked, on their own ticket page, whether it sorted things out. Both answers count as confirmation — “you did not fix it” is a recorded outcome, and counting only the praise would flatter the number. Satisfaction is shown as a share of the requests that were actually rated, never of everything, because dividing by silence makes a good score look bad and hides a bad one.
The same feedback the inbox holds, read as a list of things to build rather than a queue to clear. Requests about the same thing are grouped, ranked by how many people asked, with how many of those arrived in the last 30 days so a rising theme is visible.
Grouping is literal. It matches the words people actually used, so it never invents a theme nobody asked for and it gives the same answer twice — which matters when you are deciding what to build. Requests asked only once are counted but not listed; a theme appears once two people have asked for the same thing.
Underneath sits the other half: questions your help centre could not answer, ranked by how many people hit them. Feedback tells you what to build; that list tells you what to write.
How you compare
Where enough products are using MacroReply, the reports page shows the typical first response, resolution and outcome rate across them. Medians across products rather than across requests, so one very large customer cannot set everyone's benchmark. Nothing identifying any individual product is shown or stored — and no comparison is published at all until enough products contribute that a median cannot point at one of them.
Each message gets an unguessable token when it is stored, and that token is a private page where the sender can read the thread and write back — no password, no account, no login wall between a customer and the answer they asked for.
When your team replies, the sender is emailed the reply and the link. When the sender replies, your team is alerted, and a ticket already marked done returns to open — because someone writing back is the clearest possible signal that it wasn't finished.
Internal notes. Tick Internal next to the reply box and the note stays in your inbox: it is not emailed, and it never appears on the sender's ticket page. Notes are marked in amber in the thread, and the toggle resets after every send, so a note is always a deliberate act. Only public replies count as your first response.
Senders can attach screenshots and files: paste an image straight into the message box, drag one onto the panel, or pick a file. Attachments appear on the message in your inbox and on the sender's own ticket page.
| Rule | Detail | Limit |
|---|---|---|
| Types | PNG, JPEG, GIF, WebP, PDF, plain text | 6 types |
| Per file | Larger files are refused, not truncated | 5 MB |
| Per message | Extra files are dropped | 5 files |
| Uploads | Per widget key, per hour | 60 |
The file type is decided by reading the bytes, not by what the browser claims — a file named shot.png that is really something else is refused. Stored files are served from a separate storage domain rather than from MacroReply, so an uploaded file can never run code against your dashboard, and everything except an image is sent as a download rather than opened in the browser.
The URL of a stored file is long and unguessable, but anyone holding it can open it without signing in — that is what lets an image preview inline in an issue tracker later. Treat an attachment link like the file itself.
Three switches, per project.
| Setting | Effect | Default |
|---|---|---|
| notifyOnNew | Emails every project member on new feedback and on customer replies | on |
| confirmSubmitter | Emails the sender a receipt and their ticket link | on |
| slackWebhookUrl | Posts new feedback and customer replies to a Slack channel | off |
Slack takes an incoming webhook from api.slack.com — the URL must begin https://hooks.slack.com/services/. Leave it blank to turn Slack off. Every notification is best-effort: a failed send is logged and never blocks the message from being stored.
New requests arrive in a channel as a card your team can act on, and anything that goes past its response target raises an alert there.
Not switched on yet. The steps below are what connecting will look like; the MacroReply Slack app isn't published, so there is nothing to install today.
Connecting Slack
- 01
Open Settings → Notifications
You'll need to be an administrator of the project. Whichever project is selected is the one being connected — check the switcher at the top of the sidebar first. - 02
Press Add to Slack
Slack asks which workspace to install into. If you belong to more than one, pick it from the dropdown in the top right of Slack's page. - 03
Choose the channel
On the same Slack page, pick where requests should be posted. A private channel works, but you must invite the app to it afterwards with/invite @MacroReply— Slack cannot post to a private channel it isn't in. - 04
Press Allow
MacroReply asks only to post messages. Slack returns you to Settings, which now names the workspace and channel it is connected to. - 05
Set response targets, if you want the alerts
UnderResponse times. Without targets nothing is ever overdue, so the alert never fires — the cards still arrive.
What your team can do from Slack
Each card carries the request, who sent it and the page it came from, with buttons to mark it Working on it or Resolved and a link into the inbox. Pressing a button updates the request straight away; only the person who pressed it sees the confirmation, so the channel isn't filled with acknowledgements.
The card never carries the reporter's private ticket link. A channel is much wider than your inbox, and that link lets whoever holds it reply as the customer.
If something doesn't work
| What you see | Usually means | Fix |
|---|---|---|
| Nothing arrives in the channel | The app isn't in a private channel | /invite @MacroReply |
| Cards arrive, no overdue alerts | No response targets are set | Settings → Response times |
| The same request twice | An old incoming webhook is still set | Clear it; the app supersedes it |
| Buttons do nothing | The app was removed from Slack | Reconnect from Settings |
Removing the app in Slack stops everything at once. If you were using an incoming webhook before, it keeps working and switches off by itself once the app is connected, so the same request never arrives twice.
A project has members. An admin can invite people, edit the widget and notification settings, rename the project and delete it. A member reads the inbox and replies. Inviting an address that has never signed up creates their account, and they are emailed to say so — Google sign-in is how they get in.
Turn a bug report into a GitHub issue in one click — and when you close that issue, the person who reported it hears about it automatically.
Not switched on yet. The steps below are what connecting will look like; the MacroReply GitHub App isn't published, so there is nothing to install today. Everything else on this page works now.
What it does. Every request in your inbox gets a Create issue button. The issue opens in the repository you choose, carrying the report, the page it came from, any context your site attached, and the screenshots — images embedded so they preview inline. The request keeps a link to the issue, and the issue links back to your inbox.
Closing the issue closes the loop. Mark it closed on GitHub and the request is marked resolved here, and the person who reported it is emailed to say the thing they asked for has shipped. Reopen the issue and the request reopens with it. You never have to remember who to tell.
Connecting a repository
- 01
Install the MacroReply app on your repositories
From Settings → GitHub, follow the install link and pick the repositories MacroReply may open issues in. You can choose a single repo; it never sees anything you don't select, and you can change or revoke the selection on GitHub at any time. - 02
Copy the installation ID
After installing, GitHub sends you to a page whose address ends in/installations/12345678. That number is the installation ID. - 03
Paste it into Settings → GitHub
Enter the installation ID and the repository asowner/name— exactly as it appears in your repository's address. The panel confirms when the connection is live. - 04
Create your first issue
Open any request and pressCreate issue. The button is replaced by a link to the issue. Close that issue on GitHub and watch the request turn resolved here a moment later.
What is sent to GitHub
Only what you would put in the issue yourself: the message, the reporter's name or email if they gave one, the page they were on, the context your site attached, and links to any files they uploaded. Attachment links are long and unguessable but not password-protected, so anyone with the issue can view them — which is what lets a screenshot preview inline.
What is never sent. The reporter's private ticket link stays out of the issue. That link lets whoever holds it write replies as that person, so it never goes anywhere your repository collaborators could read it. Internal notes stay in your inbox too.
If something doesn't work
| What you see | Usually means | Fix |
|---|---|---|
| Create issue is missing | No repository connected for this project | Settings → GitHub |
| Couldn't create the issue | The app was removed from that repository, or the name is wrong | Re-install, check owner/name |
| Issue closed, request still open | The app isn't receiving issue events | Re-install the app |
| Reporter wasn't emailed | They never gave an email address | Nothing to fix |
Removing the app on GitHub stops everything immediately. Requests keep their issue links, and nothing already created is deleted.
A page where the people who write in can sign in and see their own requests. Give the portal an address in Settings and it goes live at that address; clear the address and it switches off. Set a heading, an accent colour, and optionally remove the MacroReply footer — the portal wears your branding, not ours.
Email from your own address. By default everything your customers receive comes from MacroReply. Set a sender address in Settings and we hand you the DNS records to publish; once your provider can see them, your customers hear from you instead. Until then mail keeps going out from MacroReply — sending from a domain whose records are not live yet lands in spam and damages that domain, so it is never done optimistically.
A subdomain such as mail.acme.com keeps this separate from the rest of your email. Turning off the MacroReply footer clears it from the portal, the emails your customers receive, and their ticket page.
Signing in. Customers get a one-time link by email — no password to choose, forget or reset. The link works once and expires after 15 minutes. Asking for a link always says the same thing whether or not the address has ever written in, so the form can't be used to find out who your customers are.
Organisations. Group customers by their email domain, then appoint an administrator who can see every request their company filed. Shared providers like gmail.com are refused: they identify a mailbox, not a company, and treating one as an organisation would show unrelated people each other's requests.
Administrators are appointed by you. There is deliberately no way for a customer to promote themselves — signing in proves who someone is, never what they may read. And the portal shows only the request, its status and its attachments: priority, assignee, internal notes and the metadata your site attached all stay in your inbox.
Show your own data beside a request. Point MacroReply at an endpoint of yours and, when someone opens a request, a panel appears with whatever your API answers — their plan, their account state, how much they owe you.
Setting it up
- 01
Give it an endpoint
In Settings → Developers, set an endpoint likehttps://api.yourcompany.com/customers?email={{email}}. It must be https and must not resolve to a private network. - 02
Fill in what it needs
{{email}},{{name}},{{orgId}},{{contactId}}, and{{meta.anything}}for the context your widget attaches. Values are URL-encoded, so one cannot bolt an extra parameter onto the request. - 03
Add a credential if you need one
An optional header, written asHeader-Name: value, is sent with every lookup. Use a read-only credential — it is stored on our side. - 04
Open a request
PressLook up this customerand the panel fills in.
What your endpoint should return
A flat JSON object. Top-level values are shown as rows, one level of nesting is flattened to account.tier, and an array is summarised as a count rather than dumped. Empty values are skipped, at most 40 rows are shown, and long values are truncated. Everything renders as plain text, never as markup.
a good response
{
"plan": "Enterprise",
"seats": 240,
"renews_on": "2027-01-31",
"account": { "tier": "gold", "owner": "Marta Silva" },
"open_invoices": [ 1, 2 ]
}Why some endpoints are refused
Requests are made from our servers, so an endpoint that points inside a private network — a loopback address, a company intranet, or a cloud metadata service — is refused. That check runs against the addresses your hostname actually resolves to, not the name, and every address is checked rather than just the first. Redirects are not followed, because a permitted host could otherwise redirect us somewhere that isn't. Lookups time out after a few seconds and the response size is capped, so a slow or enormous reply never holds up your inbox.
Lookups happen when someone opens a request, not when it arrives. A plan captured at ingest would be a stale snapshot dressed up as current.
The widget is a client of the same public endpoints you can call yourself — to forward feedback from a native app, a CLI, or a server you already run.
POST /api/ingest — create a message
curl -X POST https://macroreply.com/api/ingest \
-H "Content-Type: application/json" \
-d '{
"key": "mr_your_project_key",
"type": "bug",
"body": "Export to CSV fails on invoices over 500 rows.",
"email": "ana@customer.com",
"name": "Ana Rojas",
"meta": { "plan": "pro", "appVersion": "3.2.1" },
"pageUrl": "https://app.example.com/invoices"
}'| Response | Meaning | Body |
|---|---|---|
| 201 | Stored, notifications dispatched | { "ok": true } |
| 400 | Missing key or empty message | { "error": … } |
| 401 | No project matches that key | { "error": … } |
To attach a file from your own code, POST it to /api/upload with the same widget key first; the response hands back an attachment record and a signature. Pass those straight through in an attachments array on the ingest call — the signature is checked, and an attachment without a valid one is dropped rather than stored.
Only key and body are required. An unrecognised type becomes other rather than an error, so a bad enum never costs you a customer's report. The endpoint is CORS-open because the widget runs on your domain, not ours.
POST /api/ticket/reply — reply as the sender
curl -X POST https://macroreply.com/api/ticket/reply \
-H "Content-Type: application/json" \
-d '{ "token": "<ticket token>", "body": "Still failing on 512 rows." }'GET /api/widget-config — the launcher's own config
curl "https://macroreply.com/api/widget-config?key=mr_your_project_key"A REST API for reading and updating requests from your own systems — a dashboard, a script, an automation tool. Everything the inbox does, it does through the same data.
Authentication
Create a key in Settings → Developers. It is shown once, at creation, and never again: only a hash of it is stored, so we cannot show it to you a second time and nobody can take a working key from our database. Lose it and you revoke it and make another.
A key is scoped to the project it was created in. It cannot see or change anything in another project, and asking for a request that belongs to one answers 404 rather than 403 — the API never confirms that an id exists somewhere you cannot reach.
every request
curl https://macroreply.com/api/v1/messages -H "Authorization: Bearer mrk_live_your_key_here"Rate limits and errors
| Status | Meaning | What to do |
|---|---|---|
| 200 / 201 | Success | — |
| 400 | A field or filter value was not recognised | Fix the value; nothing was changed |
| 401 | Missing, malformed, revoked or unknown key | Check the Authorization header |
| 404 | No such request in this project | Check the id and the key's project |
| 429 | Over 600 requests in an hour, per key | Back off and retry |
An error body is always { "error": { "status", "message" } }. Unknown filter values are an error rather than being ignored — silently returning everything because someone misspelled a status is how a script changes the wrong things.
The request object
shape returned everywhere
{
"id": "kP2n…",
"type": "bug", // bug · feature · question · other
"status": "open", // new · open · waiting · done
"priority": "high", // low · normal · high · urgent
"channel": "widget", // widget · email · api · unknown
"body": "Export to CSV fails on invoices over 500 rows.",
"from": {
"name": "Ana Rojas",
"email": "ana@acme.com",
"contact_id": "c_…",
"organization_id": "o_…"
},
"page_url": "https://app.example.com/invoices",
"metadata": { "plan": "pro", "appVersion": "3.2.1" },
"attachments": [
{ "id": "…", "name": "shot.png", "content_type": "image/png", "size": 84213, "url": "https://…" }
],
"assignee_id": null,
"github_issue_url": null,
"created_at": "2026-09-09T14:03:11.000Z",
"first_responded_at": null,
"resolved_at": null
}The customer's private ticket link is deliberately absent. It is their access grant — whoever holds it can post replies as them — and no integration needs it.
List requests
GET /api/v1/messages
curl "https://macroreply.com/api/v1/messages?status=new&priority=urgent&limit=20" -H "Authorization: Bearer mrk_live_…"
# → { "data": [ …request objects… ], "has_more": false, "total": 3 }| Parameter | Values | Default |
|---|---|---|
| status | new · open · waiting · done | all |
| priority | low · normal · high · urgent | all |
| type | bug · feature · question · other | all |
| channel | widget · email · api | all |
| limit | 1–200 | 50 |
Create a request
POST /api/v1/messages
curl -X POST https://macroreply.com/api/v1/messages -H "Authorization: Bearer mrk_live_…" -H "Content-Type: application/json" -d '{
"body": "Billing page 500s on annual plans.",
"type": "bug",
"email": "ana@acme.com",
"name": "Ana Rojas"
}'Only body is required. The request is recorded with channel api, and your rules apply exactly as they do to anything arriving through the widget or by email — the door does not change the routing.
Read one request, with its thread
GET /api/v1/messages/{id}
curl https://macroreply.com/api/v1/messages/kP2n… -H "Authorization: Bearer mrk_live_…"
# → { "data": { …request…, "replies": [ … ] } }Update a request
PATCH /api/v1/messages/{id}
curl -X PATCH https://macroreply.com/api/v1/messages/kP2n… -H "Authorization: Bearer mrk_live_…" -H "Content-Type: application/json" -d '{ "status": "done", "priority": "high", "assignee_id": "acct_…" }'Any of status, priority, type and assignee_id. Changing the type is recorded as a correction, exactly as if someone had re-typed it in the inbox — those corrections are what make classification better over time, so they are kept rather than overwritten.
Reply to a request
POST /api/v1/messages/{id}/replies
curl -X POST https://macroreply.com/api/v1/messages/kP2n…/replies -H "Authorization: Bearer mrk_live_…" -H "Content-Type: application/json" -d '{ "body": "Fixed in 3.2.2 — thanks for the report.", "author": "Ana" }'A reply is public by default and emails the customer. Pass "visibility": "internal" for a note that stays in your inbox. The default is public on purpose: an integration that forgets the field should send the customer an answer rather than quietly file a note nobody reads.
Anything longer is truncated, not rejected — a message over the cap still reaches you.
| Field | Rule | Limit |
|---|---|---|
| body | Truncated | 5,000 chars |
| Truncated | 320 chars | |
| name | Truncated | 200 chars |
| pageUrl | Dropped unless http(s) | 1,000 chars |
| meta | Extra keys dropped | 20 keys |
| meta key | Truncated | 64 chars |
| meta value | Nested objects dropped | 500 chars |
The widget key is public page source, so every field arriving at ingest is treated as hostile. Metadata is flattened to bounded strings and keys that could poison an object prototype are discarded. A pageUrl is kept only if it parses as http or https, so a javascript: URL can never become a clickable link in your inbox.
End users on your site are told, in the widget itself, that their message goes to MacroReply, with a link to the privacy policy. That notice is not configurable — they have no other way to know a third party receives what they type.
— — — end of message — — —
Something missing? Start free and send it through the widget — it arrives in our own inbox.