FeedLetter API v2
Create surveys, retrieve results and feedback, search your account, and load Wall of Love feedback. Complete request and response reference with copyable curl examples.
The FeedLetter API v2 is available on all plans. Create newsletter surveys, one-time polls, and web rating widgets, then read their settings, votes, and feedback. Your account’s plan limits still apply. On the Business plan, you can also load the feedback of a Wall of Love to build your own wall.
Base URL: https://api.feedletter.co/v2
| Method | Path | Key access | Purpose |
|---|---|---|---|
POST | /surveys | Read & write | Create a survey, or return one with the same external_id. |
GET | /surveys/{id} | Read or Read & write | Get settings, placement code, analytics, and feedback. |
GET | /surveys | Read or Read & write | List or search surveys, 50 per page. |
GET | /walls-of-love/{id} | Read or Read & write | Business plan only. Get the feedback a Wall of Love shows, to build your own wall. |
These are the supported v2 operations. Updating surveys, deleting surveys, and submitting votes or feedback are not exposed through this API. Respondents vote through the returned links or widget. See Survey Types Explained to choose a type.
Quickstart
These examples use Bash, Zsh, or a POSIX-compatible shell, and curl 7.76.0 or newer for --fail-with-body. They do not require jq. Run this setup once in the same terminal, replacing the key placeholder with a real Read & write key:
export FEEDLETTER_API_KEY='fl_REPLACE_WITH_YOUR_API_KEY'
export FEEDLETTER_API_BASE='https://api.feedletter.co/v2'
Create a two-answer poll. The external_id identifies this survey in your system; reuse it when retrying the same creation, and choose a new one for a different survey.
curl --silent --show-error --fail-with-body \
--request POST "$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--header 'Content-Type: application/json' \
--data-binary @- <<'JSON'
{
"type": "poll",
"name": "Documentation feedback",
"external_id": "docs-feedback-1",
"text_template": "friendly",
"satisfaction_survey": true,
"voting": {
"question": "Was this guide useful?",
"introduction": "Choose an answer:",
"answers": ["Yes", "No"]
}
}
JSON
On first creation the status is 201 Created. A representative response is:
{
"survey": {
"id": "3befdcd0-c664-4faa-98a2-f7c5c11295b6",
"type": "poll",
"name": "Documentation feedback",
"external_id": "docs-feedback-1",
"links": [
{ "answer": 1, "text": "Yes", "url": "https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/1" },
{ "answer": 2, "text": "No", "url": "https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/2" }
],
"email_html": "<p><strong>Was this guide useful?</strong></p><p>Choose an answer:</p><ul><li><a href=\"https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/1\" target=\"_blank\">Yes</a></li><li><a href=\"https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/2\" target=\"_blank\">No</a></li></ul>"
}
}
Copy your response’s survey.id into the variable below, then retrieve the survey. IDs and timestamps in this documentation are illustrative; use the values returned to you.
export FEEDLETTER_SURVEY_ID='REPLACE_WITH_RETURNED_SURVEY_ID'
curl --silent --show-error --fail-with-body \
"$FEEDLETTER_API_BASE/surveys/$FEEDLETTER_SURVEY_ID" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY"
Use survey.links to place individual voting links, or paste the decoded survey.email_html string into your email editor’s HTML view. Creation returns placement code; GET also returns settings, analytics, and feedback. Response fields describes both shapes.
The curl flags show JSON, report network failures, and return a nonzero exit code for HTTP errors while preserving the error body. Add --include when you also want to inspect the HTTP status and response headers.
Authentication
- Open Account → API Keys in FeedLetter. Only the account owner can manage keys.
- Create a Read or Read & write key and copy it immediately. New keys start with
fl_and are shown only once. - Send
Authorization: Bearer YOUR_KEYwith every request, as shown in the examples.
Read keys can get and search surveys. Creating requires Read & write; a Read key receives 403 insufficient_access. To change access, create a replacement key and revoke the old one.
Keys are scoped to their account. Keep them in server-side configuration or a secret store and call this API from your backend. Returned voting links and widget snippets can be shared without including your key.
Missing, malformed, unknown, revoked, and legacy keys receive 401 unauthorized. Legacy keys work only with API v1; v1 and v2 keys are not interchangeable.
Create a survey
POST /surveys · Read & write · 201 created, or 200 already exists
Send one JSON object with Content-Type: application/json. The maximum body is 65,536 bytes (64 KiB). A missing Content-Type header is currently accepted, but an explicitly supplied different media type receives 415. Always send the header in clients.
Only type and name are required. All examples use the variables from Quickstart. Optional settings are defined in Request fields.
Newsletter survey
Newsletters have exactly three answers, ordered positive, neutral, negative. This example explicitly selects friendly texts; saved newsletter settings may still apply.
curl --silent --show-error --fail-with-body \
--request POST "$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--header 'Content-Type: application/json' \
--data-binary @- <<'JSON'
{
"type": "newsletter",
"name": "Issue #42",
"external_id": "newsletter-issue-42",
"text_template": "friendly",
"voting": {
"question": "Was this issue useful?",
"answers": ["Useful", "Somewhat useful", "Not useful"]
}
}
JSON
The response contains links and email_html, as in the quickstart. To prefill a reader’s email, append your email provider’s merge tag to the voting URL, for example ?e={{EmailAddress}}, and keep advanced.prefill_email enabled. Use your provider’s actual syntax; see ESP Connections.
One-time poll
Polls accept one to five answers. Answers four and five require Pro. For choices without a positive/negative meaning, leave satisfaction_survey false:
curl --silent --show-error --fail-with-body \
--request POST "$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--header 'Content-Type: application/json' \
--data-binary @- <<'JSON'
{
"type": "poll",
"name": "Next workshop topic",
"external_id": "workshop-topic-1",
"text_template": "casual",
"satisfaction_survey": false,
"voting": {
"question": "Which topic should we cover next?",
"answers": ["Pricing", "Onboarding", "Retention"]
},
"thank_you_page": {
"show_result": true
}
}
JSON
Polls return links and email_html. Setting satisfaction_survey to true enables sentiment and email statistics; it does not change answer labels. Arrange satisfaction answers from positive to negative.
Web rating widget
The rating type controls the answer labels and number of comment prompts. This five-star example works on all plans:
curl --silent --show-error --fail-with-body \
--request POST "$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--header 'Content-Type: application/json' \
--data-binary @- <<'JSON'
{
"type": "web_rating",
"name": "Docs: getting started",
"external_id": "docs-getting-started-rating",
"rating": {
"question": "How helpful was this page?",
"rating_type": "five_stars"
},
"extended_feedback_page": {
"comment_prompts": [
"What was most helpful?",
"What worked well?",
"What was missing?",
"What could we improve?",
"What went wrong?"
]
}
}
JSON
Representative 201 response:
{
"survey": {
"id": "21ef48ef-75b0-4a02-b0ad-371bd6310163",
"type": "web_rating",
"name": "Docs: getting started",
"external_id": "docs-getting-started-rating",
"embed_html": "<script>var feedletterFormId='21ef48ef-75b0-4a02-b0ad-371bd6310163'</script><script type=\"text/javascript\" defer src=\"https://feedletter.co/embed/widget.js\"></script>"
}
}
Paste the decoded embed_html string before </body> for a floating widget. For an inline widget, also add <div id="feedletter-widget"></div> where it should appear. No API key belongs in the embed code.
Copy an existing survey
copy_from copies answers, texts, and settings from a survey in your account and of the same type, without votes or feedback. Supply a new name and, if used, a new external_id. public_title defaults to the new name. Individual settings can be overridden subject to plan access.
After the quickstart, FEEDLETTER_SURVEY_ID identifies a poll. This example copies it. The unquoted heredoc delimiter intentionally lets the shell insert the returned survey ID into the JSON.
curl --silent --show-error --fail-with-body \
--request POST "$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--header 'Content-Type: application/json' \
--data-binary @- <<JSON
{
"type": "poll",
"name": "Documentation feedback: next release",
"external_id": "docs-feedback-2",
"copy_from": "$FEEDLETTER_SURVEY_ID",
"voting": {
"question": "Was the updated guide useful?"
}
}
JSON
Do not combine nonempty copy_from and text_template: the request receives 422 invalid_field. An unavailable source or a source of another type also receives 422 invalid_field on copy_from.
Text templates and defaults
An omitted optional field keeps the base value selected as follows:
| Creation mode | Base texts and settings |
|---|---|
copy_from supplied | The source survey’s answers and settings, followed by allowed request overrides. |
Newsletter with saved defaults, no text_template | Your saved Newsletter Defaults. |
Newsletter with text_template: "newsletter_defaults" | Saved defaults; if none exist, friendly defaults plus no_newsletter_defaults. |
Newsletter with friendly, casual, or business | That style’s texts and answer labels; saved newsletter settings retained when present. |
| Newsletter without saved defaults, no template | Friendly defaults. |
| Poll or web rating without a template | Friendly texts and the type’s built-in settings. |
| Poll or web rating with a template | The selected friendly, casual, or business texts and built-in settings. |
newsletter_defaults is newsletter-only. Changing rating_type selects the corresponding answer labels and comment prompts; supply comment_prompts to customize them. When copying a web rating and changing its rating type, omitted prompts reset to the friendly prompts for the new scale.
Plan limits and warnings
The minimum plan in the field tables is the plan needed to override that field. Templates, saved defaults, or a copied survey can already supply its value.
An otherwise valid override above your plan is ignored with a field_locked warning; the base value remains. On Free, explicitly supplied poll answers four and five and their matching headlines are dropped with individual warnings. voting.answers[3] identifies the fourth item: warning paths use zero-based indexes.
Fields are validated even if your plan cannot override them. Wrong types, malformed URLs, and incorrect array lengths still fail. If five poll answers and headlines are supplied, send five headlines even on Free; headline length is checked against the requested answers, not the retained answers.
Create returns a summary. Use GET to inspect effective settings, especially after warnings. Exceeding the Free account’s survey allowance returns 403 plan_limit_reached.
Duplicates and safe creation retries
A nonempty external_id is unique across survey types in your account. If it exists, a valid create request returns 200 OK, the existing survey’s creation-summary shape, and already_exists. Nothing is updated. The returned survey may have a different name or type than the new request.
{
"survey": {
"id": "21ef48ef-75b0-4a02-b0ad-371bd6310163",
"type": "web_rating",
"name": "Docs: getting started",
"external_id": "docs-getting-started-rating",
"embed_html": "<script>var feedletterFormId='21ef48ef-75b0-4a02-b0ad-371bd6310163'</script><script type=\"text/javascript\" defer src=\"https://feedletter.co/embed/widget.js\"></script>"
},
"warnings": [
{
"code": "already_exists",
"field": "external_id",
"message": "A survey with this external_id exists; nothing was created or changed."
}
]
}
Duplicate lookup happens after request and source/template validation, but before the survey-count limit. Invalid fields or an unavailable copy source can still make a retry fail. Retry the same valid payload with the same external_id. Without it, repeated POSTs can create multiple surveys.
Request fields
Fields follow the app’s Voting (Rating for web ratings), Extended Feedback Page, Thank You Page, and Advanced sections. Use these public names exactly. Unknown fields receive 422 unknown_field; recognized fields for another type receive 422 field_not_supported.
Table conventions: “All” means all three survey types; “Links” means newsletter and poll. Every field is optional unless marked required. “Base” means the inherited value from Text templates and defaults. Built-in defaults can be replaced by saved defaults or a copy source.
Text, URL, email, and top-level string fields are trimmed at both ends. The rating_type enum must match exactly, without surrounding whitespace. Text limits count Unicode characters; URL limits count bytes. Booleans are JSON true/false, not quoted strings or 0/1.
Top-level fields
| Field | JSON type | Rules and default |
|---|---|---|
type | string | Required. Exactly newsletter, poll, or web_rating. |
name | string | Required. Nonempty after trimming, maximum 255 characters. Internal name. |
public_title | string or null | Maximum 1,000 characters. Title on public pages such as the Wall of Love. Missing/null/empty uses name, including when copying. |
external_id | string or null | Maximum 255 characters; account-wide identifier. Missing/null means unset; empty or whitespace-only is invalid. See Duplicates. |
text_template | string or null | friendly, casual, business, or newsletter-only newsletter_defaults. Missing/null/empty selects the automatic base. Mutually exclusive with nonempty copy_from. |
copy_from | string or null | Source survey ID, maximum 255 characters; same account and type. Missing/null/empty means no copy. |
satisfaction_survey | boolean or null | Poll only, Free. Enables sentiment and email statistics. Base value; built-in default false. |
voting | object or null | Links only. Fields below. |
rating | object or null | Web rating only. Fields below. |
extended_feedback_page | object or null | All; individual fields depend on type. |
thank_you_page | object or null | All; individual fields depend on type. |
advanced | object or null | All; individual fields depend on type. |
Voting and rating
| Field | JSON type | Types / minimum plan | Meaning and default |
|---|---|---|---|
voting.question | string or null | Links / Free | Question, maximum 1,000 characters. Base text. |
voting.introduction | string or null | Links / Free | Introduction, maximum 1,000 characters. Base text. |
voting.answers | array of strings | Links / Free; poll items 4–5 Pro | Newsletter: exactly 3; poll: 1–5. Each answer is nonempty, maximum 1,000 characters. Omission keeps base answers; built-in polls start with 3. |
rating.question | string or null | Web rating / Free | Widget question, maximum 1,000 characters. Base text. |
rating.rating_type | string | Web rating / Free | thumbs, five_stars, or five_emojis; built-in default thumbs. Empty/null invalid. |
Extended Feedback Page
Scalar fields accept strings or null, with a 1,000-character limit. All fields inherit base texts when omitted.
| Field | JSON type | Types / minimum plan | Meaning |
|---|---|---|---|
extended_feedback_page.message_after_voting | string or null | Links / Pro | Vote-confirmation message. |
extended_feedback_page.headlines_after_voting | array of string/null entries | Links / Free; poll items 4–5 Pro | One headline per answer, in answer order; maximum 1,000 characters per entry. |
extended_feedback_page.comment_prompts | array of string/null entries | Web rating / Free | Exactly 3 prompts for thumbs, 5 for stars/emojis, best to worst; maximum 1,000 characters per entry. |
extended_feedback_page.comment_field_label | string or null | Links / Pro | Comment-field label. |
extended_feedback_page.comment_box_hint | string or null | Links / Pro | Comment-box placeholder. |
extended_feedback_page.name_placeholder | string or null | All / Pro | Empty hides the name field. |
extended_feedback_page.email_placeholder | string or null | All / Pro | Empty hides the email field. |
extended_feedback_page.button_text | string or null | All / Pro | Submit-button text. |
If voting.answers is supplied, headlines_after_voting must have the same number of entries; otherwise it must match inherited answers. The headline/prompt arrays themselves cannot be null or empty. Empty/null entries become empty strings. Answer entries cannot be empty/null.
New web ratings show name/email fields by default for subscribed accounts and hide them on Free. Newsletter and poll templates show both unless inherited settings hide them. Selecting a style preserves intentionally hidden contact fields.
Thank You Page
Text fields have a 1,000-character limit and inherit base text. URL fields accept an empty string/null to clear them, or an absolute HTTP(S) URL with a host, at most 2,000 bytes.
| Field | JSON type | Types / minimum plan | Meaning and built-in setting default |
|---|---|---|---|
thank_you_page.headline | string or null | All / Pro | Main thank-you text. |
thank_you_page.subheadline | string or null | All / Pro | Supporting text. |
thank_you_page.show_result | boolean or null | Links / Free | Show voting results; false. |
thank_you_page.cta_text | string or null | All / Pro | Call-to-action caption. |
thank_you_page.cta_url | string or null | All / Pro | Call-to-action destination; empty. |
thank_you_page.x_username | string or null | Links / Free | X username for sharing; empty. |
thank_you_page.x_share_button_text | string or null | Links / Free | X share-button caption. |
thank_you_page.redirect_url | string or null | Links / Pro | Redirect destination; empty (no redirect). |
Advanced
All fields inherit their base when omitted. These defaults apply without saved settings or a copy source.
| Field | JSON type | Types / minimum plan | Meaning and built-in default |
|---|---|---|---|
advanced.enable_extended_feedback_page | boolean or null | Links / Pro | Show the Extended Feedback Page after voting; true. |
advanced.prefill_email | boolean or null | Links / Pro | Prefill from the voting link’s e parameter; true. |
advanced.allow_empty_feedback | boolean or null | Links / Pro | Allow feedback without a comment; false. |
advanced.require_email | boolean or null | Web rating / Pro | Require email; false. Use with a nonempty email placeholder to display the field. |
advanced.show_widget_after_seconds | integer or null | Web rating / Pro | Automatic display delay, 0–3600 seconds; 10. |
advanced.enable_review_schema | boolean or null | Web rating / Pro | Enable review structured data; false. |
advanced.show_branding | boolean or null | All / Pro | Show FeedLetter branding; true. |
advanced.email_on_new_feedback | string or null | All / Pro | Notification email address, maximum 255 characters, not a boolean. Empty/null disables it; default empty. |
advanced.webhook_on_new_feedback | string or null | All / Business | Webhook URL, not a boolean. Absolute HTTP(S) URL with a host, maximum 2,000 bytes. Empty/null disables it; default empty. |
Omitted, empty, and null values
| Input | Behavior |
|---|---|
| Omitted optional field | Retain its selected base value. |
Supported group omitted, {}, or null | No overrides from that group. Any null group is treated as omitted; a non-null group belonging to another type is rejected. |
Text, URL, or email set to "" or null | Store empty if your plan allows the override. GET normally returns "" for these settings. |
Boolean set to null | Unset the stored setting; reading settings falls back to its built-in default. Send false to explicitly disable it. "" is invalid. |
show_widget_after_seconds set to null | Unset the setting; reading it falls back to 10 seconds. "", fractional numbers, and numeric strings are invalid. |
Answers/headlines/prompts array set to null | Invalid. Omit the array to keep the base. Null entries are allowed only in headlines/prompts. |
rating_type set to "" or null | Invalid; send a listed enum or omit it. |
Top-level exceptions are in the top-level table: for example, public_title: "" uses the name and external_id: "" fails. Legacy surveys may return older stored text as a fallback when a newer text setting is empty. Use GET to inspect effective settings.
Get a survey
GET /surveys/{id} · Read or Read & write · 200 OK
Replace {id} with survey.id from create or search, not your external_id. Treat IDs as opaque strings.
curl --silent --show-error --fail-with-body \
"$FEEDLETTER_API_BASE/surveys/$FEEDLETTER_SURVEY_ID" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY"
By default, GET includes settings, placement code, analytics, and all feedback. Its only supported query parameter is view:
| Parameter | Values | Default |
|---|---|---|
view | results, or empty/omitted | Full survey when empty/omitted. results omits settings and placement code. |
Request results only:
curl --silent --show-error --fail-with-body --get \
"$FEEDLETTER_API_BASE/surveys/$FEEDLETTER_SURVEY_ID" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--data-urlencode 'view=results'
Other view values (including all) receive 422 invalid_field. Other parameters, including page or date filters, receive 422 unknown_field. Feedback and daily votes are not paginated or date-filtered. A missing survey or a survey in another account receives 404 not_found.
Search and pagination
GET /surveys · Read or Read & write · 200 OK
| Parameter | Rules |
|---|---|
q | Optional string, maximum 255 characters after trimming. Matches name, id, or external_id. Empty/omitted lists all. |
page | Optional integer from 1 through 100000; empty/omitted means 1. Fixed page size of 50. |
List the first page:
curl --silent --show-error --fail-with-body \
"$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY"
Use * for any sequence of characters: Issue* is a prefix search, *Issue* searches anywhere. Without *, the entire value must match. Matching is case-insensitive for ASCII letters; do not rely on Unicode case folding. %, _, and backslash are literal characters. public_title is not searched.
curl --silent --show-error --fail-with-body --get \
"$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--data-urlencode 'q=Issue*' \
--data-urlencode 'page=1'
For the next page, keep the same q and increment page:
curl --silent --show-error --fail-with-body --get \
"$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--data-urlencode 'q=Issue*' \
--data-urlencode 'page=2'
Search for an external ID exactly:
curl --silent --show-error --fail-with-body --get \
"$FEEDLETTER_API_BASE/surveys" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY" \
--data-urlencode 'q=docs-feedback-1'
Exact search still checks all three fields. Another survey’s name can match your external ID. Verify each returned external_id for exact equality and continue pagination if necessary. Choose external IDs without * for literal searches; there is no separate external-ID filter or literal-star escape.
Representative response to that external-ID search:
{
"surveys": [
{
"id": "3befdcd0-c664-4faa-98a2-f7c5c11295b6",
"type": "poll",
"name": "Documentation feedback",
"public_title": "Documentation feedback",
"external_id": "docs-feedback-1",
"created_at": "2026-10-26T08:00:00Z"
}
],
"has_more": false
}
Results are newest first (created_at descending, then ID ascending for ties). Continue with page + 1 until has_more is false. No matches returns {"surveys":[],"has_more":false}. There is no total count or cursor. Pages are offset-based: changes during traversal can shift results, so deduplicate by id when collecting multiple pages.
Only q and page are accepted. Parameters such as type, external_id, limit, and offset receive 422 unknown_field. Filter returned items by type in your client.
Get a Wall of Love
GET /walls-of-love/{id} · Read or Read & write · Business plan only · 200 OK
The Wall of Love API is part of the Business plan. Keys of accounts on Hobby or Professional receive 403 plan_required, also for walls that don’t exist.
Returns the feedback a Wall of Love shows, so you can build your own wall with your own markup and styling. Open the wall in FeedLetter and copy its ID from the address bar (/walloflove/{id}) or from the embed code (https://feedletter.co/embed/wol/{id}).
export FEEDLETTER_WALL_ID='REPLACE_WITH_YOUR_WALL_ID'
curl --silent --show-error --fail-with-body \
"$FEEDLETTER_API_BASE/walls-of-love/$FEEDLETTER_WALL_ID" \
--header "Authorization: Bearer $FEEDLETTER_API_KEY"
{
"wall_of_love": {
"id": "6f1c2a9e-4d3b-4c8e-9a51-2b7d0e8f3c64",
"feedback": [
{
"id": "b8e3f0a2-1c4d-4e5f-8a9b-0c1d2e3f4a5b",
"comment": "Your newsletter is the one I read first every week.",
"name": "Jane",
"answer_label": "😍",
"created_at": "2026-10-21T09:14:03Z",
"survey_title": "Issue #42"
},
{
"id": "c9f4a1b3-2d5e-4f6a-9b0c-1d2e3f4a5b6c",
"comment": "Short, useful, no fluff.",
"answer_label": "😍",
"created_at": "2026-10-14T17:40:55Z",
"survey_title": "Issue #41"
}
]
}
}
| Field | JSON type | Presence and meaning |
|---|---|---|
id | string | Always. Feedback identifier, stable across requests. |
comment | string | Always. The comment as your wall shows it, including your edits. |
name | string | Omitted for anonymous feedback. The name as your wall shows it, including your edits. |
answer_label | string | Always. The first word of the survey’s first answer, usually an emoji such as 😍. |
created_at | timestamp string | When available. Submission time. |
survey_title | string | Always. The survey’s public title, never its internal name. |
- Same feedback as the embedded wall. Only the feedback you added to the wall, newest first. Like the embed, the API leaves out feedback whose comment contains an
@or a number with three or more digits, or whose name contains an@orhttp, so emails, phone numbers, and links aren’t published. Manage which feedback appears in FeedLetter; the API has no filters or pagination. - Your design, your choices. The wall’s display settings (style, layout, labels, timestamp, survey name, and branding) don’t change the response. Every field above is always returned, and you decide what to show. For anonymous feedback, show your own label.
- HTML-escaped.
commentandnameare HTML-escaped, likecommentin survey feedback:&,<,>,', and"arrive as entities such as&and'. Insert them as HTML, for example withinnerHTML. If you insert them as text, for example withtextContentor your framework’s default escaping, decode the entities first. - Keep the key on your server. The key can read all surveys and feedback of your account, so never put it in browser JavaScript. Call the API from your backend or at build time, for example in a static site generator, and cache the result. Requests through the API don’t count as wall views.
Unknown query parameters receive 422 unknown_field. A missing wall or a wall in another account receives 404 not_found.
Response fields
Successful documented endpoints return JSON with Content-Type: application/json. Create/get responses contain survey and include warnings only when there are warnings. Search returns surveys and has_more. Wall of Love responses contain wall_of_love. Failures return error; success responses never include an error property.
Response values are never JSON null: unavailable optional properties are omitted. Empty values still exist: settings can be "", booleans false, counts 0, and collections []. Timestamps, when present, are RFC 3339 strings with a timezone, such as 2026-10-26T08:00:00Z; accept fractional seconds too. Daily dates use YYYY-MM-DD.
Survey shapes
| Property | JSON type | Where it appears |
|---|---|---|
id, type, name | string | Every survey object, including search items. type uses the three request enum values. |
external_id | string | Any shape if assigned; otherwise omitted. |
public_title | string | Full GET and search. May be empty for older surveys. |
created_at | timestamp string | Full GET and search when available. |
satisfaction_survey | boolean | Both GET views for polls only. |
voting | object | Full GET for newsletter/poll; request field names. |
rating | object | Full GET for web rating; request field names. |
extended_feedback_page, thank_you_page, advanced | object | Full GET; only fields supported by this type. Values are strings, booleans, integers, or arrays as defined above, never null. |
links | array of link objects | Create and full GET for newsletter/poll; one per answer with text. Each has integer answer, string text, and string url. |
email_html | string | Create and full GET for newsletter/poll; complete HTML fragment with question, introduction, and links. |
embed_html | string | Create and full GET for web rating; widget snippet. |
analytics | object | Both GET views. |
feedback | array of feedback objects | Both GET views, including [] when none exist. |
The create summary contains only id, type, name, optional external_id, and the appropriate placement fields. A deduplicated create has the same shape. It does not echo request settings or return timestamps/analytics.
The results-only view contains only id, type, name, optional external_id, poll-only satisfaction_survey, analytics, and feedback. Search items contain only the six identity/title/timestamp fields shown above. copy_from and text_template are creation inputs, not response fields.
Full GET example
This complete example shows the quickstart poll after ten votes and two feedback submissions. Immediately after creation its counts would be zero and daily_votes and feedback would be empty.
{
"survey": {
"id": "3befdcd0-c664-4faa-98a2-f7c5c11295b6",
"type": "poll",
"name": "Documentation feedback",
"public_title": "Documentation feedback",
"external_id": "docs-feedback-1",
"satisfaction_survey": true,
"created_at": "2026-10-26T08:00:00Z",
"voting": {
"question": "Was this guide useful?",
"introduction": "Choose an answer:",
"answers": ["Yes", "No"]
},
"extended_feedback_page": {
"message_after_voting": "Thanks! Your vote is in.",
"headlines_after_voting": ["What made you choose this option?", "What made you choose this option?"],
"comment_field_label": "Your thoughts",
"comment_box_hint": "Tell us a little more…",
"name_placeholder": "Your name (optional)",
"email_placeholder": "Your email, so we can reply (optional)",
"button_text": "Send feedback"
},
"thank_you_page": {
"headline": "Thanks for sharing your thoughts!",
"subheadline": "Your feedback helps us improve.",
"show_result": false,
"cta_text": "Visit our website",
"cta_url": "",
"x_username": "",
"x_share_button_text": "Share on X",
"redirect_url": ""
},
"advanced": {
"enable_extended_feedback_page": true,
"prefill_email": true,
"allow_empty_feedback": false,
"show_branding": true,
"email_on_new_feedback": "",
"webhook_on_new_feedback": ""
},
"links": [
{ "answer": 1, "text": "Yes", "url": "https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/1" },
{ "answer": 2, "text": "No", "url": "https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/2" }
],
"email_html": "<p><strong>Was this guide useful?</strong></p><p>Choose an answer:</p><ul><li><a href=\"https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/1\" target=\"_blank\">Yes</a></li><li><a href=\"https://a.feedletter.co/s/3befdcd0-c664-4faa-98a2-f7c5c11295b6/2\" target=\"_blank\">No</a></li></ul>",
"analytics": {
"votes": {
"total": 10,
"answers": [
{ "answer": 1, "text": "Yes", "votes": 8 },
{ "answer": 2, "text": "No", "votes": 2 }
]
},
"daily_votes": [{ "date": "2026-10-26", "votes": [8, 2] }],
"sentiment": { "net_sentiment": 60, "positive_votes": 8, "negative_votes": 2, "speak_up_rate": 20 },
"email_stats": { "sent": 0, "opens": 0, "clicks": 0 }
},
"feedback": [
{
"id": "40787572-d752-4f4a-bd8a-e946a7054ac8",
"created_at": "2026-10-26T10:00:00Z",
"answer": 2,
"answer_text": "No",
"comment": "Please add more examples."
},
{
"id": "80288792-a6a3-4ee0-9585-1ee7797200b7",
"created_at": "2026-10-26T09:00:00Z",
"answer": 1,
"answer_text": "Yes",
"comment": "The quickstart helped.",
"name": "Alex",
"email": "alex@example.com"
}
]
}
}
Results-only example
For the five-star web rating created above, GET /surveys/{id}?view=results before any activity returns:
{
"survey": {
"id": "21ef48ef-75b0-4a02-b0ad-371bd6310163",
"type": "web_rating",
"name": "Docs: getting started",
"external_id": "docs-getting-started-rating",
"analytics": {
"votes": {
"total": 0,
"answers": [
{ "answer": 1, "text": "5-⭐", "votes": 0 },
{ "answer": 2, "text": "4-⭐", "votes": 0 },
{ "answer": 3, "text": "3-⭐", "votes": 0 },
{ "answer": 4, "text": "2-⭐", "votes": 0 },
{ "answer": 5, "text": "1-⭐", "votes": 0 }
]
},
"daily_votes": [],
"sentiment": { "net_sentiment": 0, "positive_votes": 0, "negative_votes": 0, "speak_up_rate": 0 },
"widget": { "loads": 0, "opens": 0 }
},
"feedback": []
}
}
Analytics
| Field | JSON type | Meaning |
|---|---|---|
analytics.votes.total | integer | Total stored votes across all answer slots. |
analytics.votes.answers | array | Current answers in order. Each has integer answer (1-based), string text, and integer votes. |
analytics.daily_votes | array | Stored daily totals, oldest first. Each has date (YYYY-MM-DD, UTC vote date) and votes (integer array in answer order). Days without stored rows are omitted. |
analytics.sentiment | object, optional | Newsletters, web ratings, and satisfaction polls. Four numeric fields described below. |
analytics.widget | object, optional | Web ratings only: integer loads and opens event counts, not unique visitors. |
analytics.email_stats | object, optional | Newsletters and satisfaction polls only: integer sent, opens, clicks, and optional timestamp last_checked_at from ESP statistics. Without statistics, counts are zero and the timestamp is omitted. |
Array index 0 corresponds to answer 1. Answer numbers are positions, not star scores:
| Survey / scale | Answer order | Positive / negative for sentiment |
|---|---|---|
| Newsletter | Three supplied/inherited answers | 1 / 3 |
| Poll | Supplied/inherited order, 1–5 answers | Only with satisfaction_survey: true: first / last. A one-answer poll has no negative votes. |
Web rating thumbs | 1: 👍, 2: 😐, 3: 👎 | 1 / 3 |
Web rating five_stars | 1: 5 stars, 2: 4 stars, 3: 3 stars, 4: 2 stars, 5: 1 star | 1+2 / 4+5 |
Web rating five_emojis | 1: 😍, 2: 😀, 3: 😐, 4: 😟, 5: 😡 | 1+2 / 4+5 |
sentiment.positive_votes and sentiment.negative_votes are integer counts. net_sentiment = (positive_votes - negative_votes) / votes.total × 100; speak_up_rate = feedback.length / votes.total × 100. Both percentages are rounded to one decimal place and are zero without votes. Locked entries and entries without text still count toward speak-up rate.
Use votes.total as the reported total. Older surveys whose answer configuration changed can include historical votes in slots no longer represented in votes.answers. Email statistics depend on your ESP connection and its last synchronization; they are not live tracking by this endpoint.
Feedback
Both GET views return all feedback entries for the survey, with field masking by plan. There is no separate v2 feedback endpoint, pagination, or audio-download field. A feedback entry is distinct from a vote; not every voter submits feedback.
| Field | JSON type | Presence and meaning |
|---|---|---|
id | string | Always. Feedback identifier. |
created_at | timestamp string | When available. Submission time. |
answer | integer | Always. One-based answer position. |
answer_text | string | Always. Answer label. |
comment | string | Text feedback, including "" when empty. Omitted for locked and audio feedback. |
name | string | When supplied/stored. |
email | string | When supplied/stored and not locked. |
page | string | When available, the page reference captured for a web rating; may be a path rather than an absolute URL. Currently omitted from Free-plan feedback responses. |
locked | boolean | Present only as true for entries beyond the Free account’s comment allowance. Otherwise omitted. |
The Free comment allowance is account-wide, not per survey. Locked entries retain their ID, answer, and available metadata, but omit comment and email; the response includes feedback_locked. A missing comment alone does not mean locked: audio entries also omit it. Optional stored strings may be empty; handle both absence and "". Sort by created_at in your client if display order is important.
Errors, warnings, and rate limits
Errors
An error response contains only error, with string code, string message, and optional string field. Branch on code; messages are for people. field names a body field, nested path, or query parameter, and is omitted for general failures.
{
"error": {
"code": "field_not_supported",
"message": "rating isn't available for newsletter surveys",
"field": "rating"
}
}
| HTTP status | error.code | Meaning / action |
|---|---|---|
| 400 | invalid_json | Malformed body, not one JSON object, trailing JSON, or body over 64 KiB. Correct the body. |
| 401 | unauthorized | Missing/invalid/revoked/legacy key. Check bearer authentication and use a v2 key. |
| 403 | insufficient_access | Read key tried to create. Use Read & write. |
| 403 | plan_limit_reached | Account cannot create another survey. Resolve its limit. |
| 403 | plan_required | The endpoint needs a higher plan: the Wall of Love API needs Business. Upgrade the account. |
| 404 | not_found | No survey or Wall of Love with this ID in the account. |
| 415 | unsupported_media_type | Explicit Content-Type is not application/json. |
| 422 | missing_field | Required type is absent, or name is absent/empty. |
| 422 | invalid_field | Wrong type, enum, length, range, array count, combination, or copy source. Inspect field. |
| 422 | unknown_field | Unknown JSON field or unsupported query parameter. Remove it. |
| 422 | field_not_supported | Recognized field/group unavailable for this survey type. |
| 429 | rate_limited | Wait at least Retry-After seconds. |
| 500 | internal_error | Retry with backoff; keep the request ID from message for support. |
Also handle network failures and non-JSON responses from proxies or unsupported routes. Check status and content type before parsing an error; not every HTTP failure has this envelope.
Warnings
Warnings accompany successful 200/201 survey responses. Each has string code, string message, and optional string field. Without warnings the property is omitted, not an empty array.
{
"survey": {
"id": "21ef48ef-75b0-4a02-b0ad-371bd6310163",
"type": "web_rating",
"name": "Docs: getting started",
"external_id": "docs-getting-started-rating",
"embed_html": "<script>var feedletterFormId='21ef48ef-75b0-4a02-b0ad-371bd6310163'</script><script type=\"text/javascript\" defer src=\"https://feedletter.co/embed/widget.js\"></script>"
},
"warnings": [
{
"code": "field_locked",
"field": "advanced.show_widget_after_seconds",
"message": "Needs the Pro plan; the template value was kept."
}
]
}
This example would result from creating a new web rating on Free while requesting advanced.show_widget_after_seconds: 20; the built-in 10-second value remains.
warnings[].code | Meaning |
|---|---|
field_locked | Override requires a higher plan; base retained or poll answer/headline omitted. |
already_exists | Existing external_id returned unchanged; HTTP 200. |
no_newsletter_defaults | Requested saved defaults unavailable; friendly defaults used. |
feedback_locked | Feedback exceeds the account’s allowance; comment and email masked. |
Rate limit and retries
The public API v2 uses a per-client-IP token bucket: refill 2 requests per second, capacity 120 requests. Requests from the same IP share it across keys and v2 routes. A full bucket permits a burst; it does not guarantee 120 requests every second. Limiting happens before authentication, so invalid-key requests can also receive 429.
On 429, read Retry-After, an integer number of seconds. Use --include with curl to see it. Wait at least that long. For transient network errors and 500, use bounded retries with increasing delay and jitter.
GET can be retried. Retry POST only with the same stable external_id and valid payload: a lost response does not prove creation failed. Accept both 201 and duplicate 200 as success and inspect warnings. Do not retry other 4xx responses unchanged.
Client implementation checklist
- Use the v2 base URL, bearer authentication, and JSON bodies. Keep keys on your backend.
- Send only fields supported by the survey type. Distinguish omitted, empty, null, false, and zero.
- Give each logical survey a stable, account-unique
external_id. Accept201for new surveys and200withalready_existsfor duplicates. - Parse the correct response shape: create summary, full GET, results-only GET, or search. Check warnings and GET effective settings when needed.
- Treat optional properties as optional. Accept empty arrays/strings and zero counts; do not require absent fields to be null. Ignore unfamiliar response fields for forward compatibility.
- Keep one-based answers separate from zero-based indexes and star scores. Use returned labels and placement code.
- Follow
has_morewith the same query and increasing pages. Verifyexternal_idwhen looking it up, rather than selecting the first match. - Respect
Retry-After, bound retries, and preserve errors. Handle locked/audio feedback without assuming a text comment exists.