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

MethodPathKey accessPurpose
POST/surveysRead & writeCreate a survey, or return one with the same external_id.
GET/surveys/{id}Read or Read & writeGet settings, placement code, analytics, and feedback.
GET/surveysRead or Read & writeList or search surveys, 50 per page.
GET/walls-of-love/{id}Read or Read & writeBusiness 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

  1. Open Account → API Keys in FeedLetter. Only the account owner can manage keys.
  2. Create a Read or Read & write key and copy it immediately. New keys start with fl_ and are shown only once.
  3. Send Authorization: Bearer YOUR_KEY with 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 modeBase texts and settings
copy_from suppliedThe source survey’s answers and settings, followed by allowed request overrides.
Newsletter with saved defaults, no text_templateYour 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 businessThat style’s texts and answer labels; saved newsletter settings retained when present.
Newsletter without saved defaults, no templateFriendly defaults.
Poll or web rating without a templateFriendly texts and the type’s built-in settings.
Poll or web rating with a templateThe 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

FieldJSON typeRules and default
typestringRequired. Exactly newsletter, poll, or web_rating.
namestringRequired. Nonempty after trimming, maximum 255 characters. Internal name.
public_titlestring or nullMaximum 1,000 characters. Title on public pages such as the Wall of Love. Missing/null/empty uses name, including when copying.
external_idstring or nullMaximum 255 characters; account-wide identifier. Missing/null means unset; empty or whitespace-only is invalid. See Duplicates.
text_templatestring or nullfriendly, casual, business, or newsletter-only newsletter_defaults. Missing/null/empty selects the automatic base. Mutually exclusive with nonempty copy_from.
copy_fromstring or nullSource survey ID, maximum 255 characters; same account and type. Missing/null/empty means no copy.
satisfaction_surveyboolean or nullPoll only, Free. Enables sentiment and email statistics. Base value; built-in default false.
votingobject or nullLinks only. Fields below.
ratingobject or nullWeb rating only. Fields below.
extended_feedback_pageobject or nullAll; individual fields depend on type.
thank_you_pageobject or nullAll; individual fields depend on type.
advancedobject or nullAll; individual fields depend on type.

Voting and rating

FieldJSON typeTypes / minimum planMeaning and default
voting.questionstring or nullLinks / FreeQuestion, maximum 1,000 characters. Base text.
voting.introductionstring or nullLinks / FreeIntroduction, maximum 1,000 characters. Base text.
voting.answersarray of stringsLinks / Free; poll items 4–5 ProNewsletter: exactly 3; poll: 1–5. Each answer is nonempty, maximum 1,000 characters. Omission keeps base answers; built-in polls start with 3.
rating.questionstring or nullWeb rating / FreeWidget question, maximum 1,000 characters. Base text.
rating.rating_typestringWeb rating / Freethumbs, 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.

FieldJSON typeTypes / minimum planMeaning
extended_feedback_page.message_after_votingstring or nullLinks / ProVote-confirmation message.
extended_feedback_page.headlines_after_votingarray of string/null entriesLinks / Free; poll items 4–5 ProOne headline per answer, in answer order; maximum 1,000 characters per entry.
extended_feedback_page.comment_promptsarray of string/null entriesWeb rating / FreeExactly 3 prompts for thumbs, 5 for stars/emojis, best to worst; maximum 1,000 characters per entry.
extended_feedback_page.comment_field_labelstring or nullLinks / ProComment-field label.
extended_feedback_page.comment_box_hintstring or nullLinks / ProComment-box placeholder.
extended_feedback_page.name_placeholderstring or nullAll / ProEmpty hides the name field.
extended_feedback_page.email_placeholderstring or nullAll / ProEmpty hides the email field.
extended_feedback_page.button_textstring or nullAll / ProSubmit-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.

FieldJSON typeTypes / minimum planMeaning and built-in setting default
thank_you_page.headlinestring or nullAll / ProMain thank-you text.
thank_you_page.subheadlinestring or nullAll / ProSupporting text.
thank_you_page.show_resultboolean or nullLinks / FreeShow voting results; false.
thank_you_page.cta_textstring or nullAll / ProCall-to-action caption.
thank_you_page.cta_urlstring or nullAll / ProCall-to-action destination; empty.
thank_you_page.x_usernamestring or nullLinks / FreeX username for sharing; empty.
thank_you_page.x_share_button_textstring or nullLinks / FreeX share-button caption.
thank_you_page.redirect_urlstring or nullLinks / ProRedirect destination; empty (no redirect).

Advanced

All fields inherit their base when omitted. These defaults apply without saved settings or a copy source.

FieldJSON typeTypes / minimum planMeaning and built-in default
advanced.enable_extended_feedback_pageboolean or nullLinks / ProShow the Extended Feedback Page after voting; true.
advanced.prefill_emailboolean or nullLinks / ProPrefill from the voting link’s e parameter; true.
advanced.allow_empty_feedbackboolean or nullLinks / ProAllow feedback without a comment; false.
advanced.require_emailboolean or nullWeb rating / ProRequire email; false. Use with a nonempty email placeholder to display the field.
advanced.show_widget_after_secondsinteger or nullWeb rating / ProAutomatic display delay, 0–3600 seconds; 10.
advanced.enable_review_schemaboolean or nullWeb rating / ProEnable review structured data; false.
advanced.show_brandingboolean or nullAll / ProShow FeedLetter branding; true.
advanced.email_on_new_feedbackstring or nullAll / ProNotification email address, maximum 255 characters, not a boolean. Empty/null disables it; default empty.
advanced.webhook_on_new_feedbackstring or nullAll / BusinessWebhook 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

InputBehavior
Omitted optional fieldRetain its selected base value.
Supported group omitted, {}, or nullNo 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 nullStore empty if your plan allows the override. GET normally returns "" for these settings.
Boolean set to nullUnset 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 nullUnset the setting; reading it falls back to 10 seconds. "", fractional numbers, and numeric strings are invalid.
Answers/headlines/prompts array set to nullInvalid. Omit the array to keep the base. Null entries are allowed only in headlines/prompts.
rating_type set to "" or nullInvalid; 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:

ParameterValuesDefault
viewresults, or empty/omittedFull 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

ParameterRules
qOptional string, maximum 255 characters after trimming. Matches name, id, or external_id. Empty/omitted lists all.
pageOptional 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"
      }
    ]
  }
}
FieldJSON typePresence and meaning
idstringAlways. Feedback identifier, stable across requests.
commentstringAlways. The comment as your wall shows it, including your edits.
namestringOmitted for anonymous feedback. The name as your wall shows it, including your edits.
answer_labelstringAlways. The first word of the survey’s first answer, usually an emoji such as 😍.
created_attimestamp stringWhen available. Submission time.
survey_titlestringAlways. 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 @ or http, 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. comment and name are HTML-escaped, like comment in survey feedback: &, <, >, ', and " arrive as entities such as &amp; and &#39;. Insert them as HTML, for example with innerHTML. If you insert them as text, for example with textContent or 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

PropertyJSON typeWhere it appears
id, type, namestringEvery survey object, including search items. type uses the three request enum values.
external_idstringAny shape if assigned; otherwise omitted.
public_titlestringFull GET and search. May be empty for older surveys.
created_attimestamp stringFull GET and search when available.
satisfaction_surveybooleanBoth GET views for polls only.
votingobjectFull GET for newsletter/poll; request field names.
ratingobjectFull GET for web rating; request field names.
extended_feedback_page, thank_you_page, advancedobjectFull GET; only fields supported by this type. Values are strings, booleans, integers, or arrays as defined above, never null.
linksarray of link objectsCreate and full GET for newsletter/poll; one per answer with text. Each has integer answer, string text, and string url.
email_htmlstringCreate and full GET for newsletter/poll; complete HTML fragment with question, introduction, and links.
embed_htmlstringCreate and full GET for web rating; widget snippet.
analyticsobjectBoth GET views.
feedbackarray of feedback objectsBoth 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

FieldJSON typeMeaning
analytics.votes.totalintegerTotal stored votes across all answer slots.
analytics.votes.answersarrayCurrent answers in order. Each has integer answer (1-based), string text, and integer votes.
analytics.daily_votesarrayStored 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.sentimentobject, optionalNewsletters, web ratings, and satisfaction polls. Four numeric fields described below.
analytics.widgetobject, optionalWeb ratings only: integer loads and opens event counts, not unique visitors.
analytics.email_statsobject, optionalNewsletters 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 / scaleAnswer orderPositive / negative for sentiment
NewsletterThree supplied/inherited answers1 / 3
PollSupplied/inherited order, 1–5 answersOnly with satisfaction_survey: true: first / last. A one-answer poll has no negative votes.
Web rating thumbs1: 👍, 2: 😐, 3: 👎1 / 3
Web rating five_stars1: 5 stars, 2: 4 stars, 3: 3 stars, 4: 2 stars, 5: 1 star1+2 / 4+5
Web rating five_emojis1: 😍, 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.

FieldJSON typePresence and meaning
idstringAlways. Feedback identifier.
created_attimestamp stringWhen available. Submission time.
answerintegerAlways. One-based answer position.
answer_textstringAlways. Answer label.
commentstringText feedback, including "" when empty. Omitted for locked and audio feedback.
namestringWhen supplied/stored.
emailstringWhen supplied/stored and not locked.
pagestringWhen 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.
lockedbooleanPresent 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 statuserror.codeMeaning / action
400invalid_jsonMalformed body, not one JSON object, trailing JSON, or body over 64 KiB. Correct the body.
401unauthorizedMissing/invalid/revoked/legacy key. Check bearer authentication and use a v2 key.
403insufficient_accessRead key tried to create. Use Read & write.
403plan_limit_reachedAccount cannot create another survey. Resolve its limit.
403plan_requiredThe endpoint needs a higher plan: the Wall of Love API needs Business. Upgrade the account.
404not_foundNo survey or Wall of Love with this ID in the account.
415unsupported_media_typeExplicit Content-Type is not application/json.
422missing_fieldRequired type is absent, or name is absent/empty.
422invalid_fieldWrong type, enum, length, range, array count, combination, or copy source. Inspect field.
422unknown_fieldUnknown JSON field or unsupported query parameter. Remove it.
422field_not_supportedRecognized field/group unavailable for this survey type.
429rate_limitedWait at least Retry-After seconds.
500internal_errorRetry 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[].codeMeaning
field_lockedOverride requires a higher plan; base retained or poll answer/headline omitted.
already_existsExisting external_id returned unchanged; HTTP 200.
no_newsletter_defaultsRequested saved defaults unavailable; friendly defaults used.
feedback_lockedFeedback 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

  1. Use the v2 base URL, bearer authentication, and JSON bodies. Keep keys on your backend.
  2. Send only fields supported by the survey type. Distinguish omitted, empty, null, false, and zero.
  3. Give each logical survey a stable, account-unique external_id. Accept 201 for new surveys and 200 with already_exists for duplicates.
  4. Parse the correct response shape: create summary, full GET, results-only GET, or search. Check warnings and GET effective settings when needed.
  5. 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.
  6. Keep one-based answers separate from zero-based indexes and star scores. Use returned labels and placement code.
  7. Follow has_more with the same query and increasing pages. Verify external_id when looking it up, rather than selecting the first match.
  8. Respect Retry-After, bound retries, and preserve errors. Handle locked/audio feedback without assuming a text comment exists.