Search

Search IconIcon to open search

Pollmd: My Minimal Survey Service (for Newsletter) that Works with Markdown

Last updatedUpdated: by Simon Späti · CreatedCreated: · 4 min read

Pollmd is my minimal (newsletter) survey with dynamic questions right from Markdown (links) with one time installation with DuckDB Quack Protocol.

I wanted to self hostit on my servers and use Markdown, that’s why I used the opportunity to try Quack Protocol from DuckDB and see how far I can get.

# Origin

I saw Tally as a beautiful and simple poll, but 24$ per month for a couple of links, I saw it as a good opportunity to try DuckDB Quack.

# Code

Pollmd on GitHub.

# Test Surveys and Polls

# Test Survey

1
2
3
4
5
What did you think of today's newsletter?

[Awesome!](https://survey.ssp.sh/survey/2026-06-04/awesome)
[Pretty Good](https://survey.ssp.sh/survey/2026-06-04/good)
[Could be better](https://survey.ssp.sh/survey/2026-06-04/better)

# Test Twitter

1
2
3
4
5
Do you like this minmal survey?
[Awesome!](https://q.ssp.sh/survey/init/awesome)
[Pretty Good](https://q.ssp.sh/survey/init/good)
[Could be better](https://q.ssp.sh/survey/init/better)
[I'm not sure](https://q.ssp.sh/survey/init/not-sure)

See the result at init:

# The Query

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  INSTALL quack;
  LOAD quack;

  CREATE OR REPLACE MACRO rq(sql) AS TABLE (
    FROM quack_query(
      'quack:' || getenv('RAILWAY_QUACK_HOST') || ':' || getenv('RAILWAY_QUACK_PORT'),
      sql,
      token => getenv('SURVEY_QUACK_TOKEN'),
      disable_ssl => true
    )
  );

  CREATE OR REPLACE VIEW remote_votes AS FROM rq('FROM votes');

  WITH t AS (
    SELECT answer, count(*) AS clicks
    FROM remote_votes
    WHERE survey_id = 'init'
    GROUP BY answer
  )
  SELECT 'init' AS survey_id, answer, clicks,
         bar(clicks, 0, (SELECT max(clicks) FROM t), 30) AS chart
  FROM t ORDER BY clicks DESC;

# Creating a Poll with Fixed Answers

1
make survey-create SURVEY_ID=2026-06-16-landing-page ANSWERS=awesome,good,ok,not-mine

that gives you the landing page at 2026-06-16-landing-page to vote or the markdown to add to your newsletter:

1
2
3
4
  [Awesome](https://q.ssp.sh/2026-06-16-landing-page/awesome)
  [Good](https://q.ssp.sh/2026-06-16-landing-page/good)
  [Ok](https://q.ssp.sh/2026-06-16-landing-page/ok)
  [Not Mine](https://q.ssp.sh/2026-06-16-landing-page/not-mine)

See full command:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

Registering survey_id='2026-06-16-landing-page' with allowed answers: awesome,good,ok,not-mine
-- Loading resources from /tmp/tmp.jbUpPhYQGZ
┌─────────────────────────┬──────────────────────────┬────────────────────────────┐
│        survey_id        │     allowed_answers      │         created_at         │
│         varchar         │         varchar          │         timestamp          │
├─────────────────────────┼──────────────────────────┼────────────────────────────┤
│ 2026-06-16-landing-page │ awesome,good,ok,not-mine │ 2026-06-16 07:43:26.588451 │
└─────────────────────────┴──────────────────────────┴────────────────────────────┘

Landing page (share this URL):
  https://q.ssp.sh/2026-06-16-landing-page

Markdown links to paste into your newsletter:

  [Awesome](https://q.ssp.sh/2026-06-16-landing-page/awesome)
  [Good](https://q.ssp.sh/2026-06-16-landing-page/good)
  [Ok](https://q.ssp.sh/2026-06-16-landing-page/ok)
  [Not Mine](https://q.ssp.sh/2026-06-16-landing-page/not-mine)

Live tally page:
  https://q.ssp.sh/result/2026-06-16-landing-page

done.

Including the results at Results: 2026-06-16-landing-page.

Landing page:

# Architecture and Flow

Single Go process. libduckdb 1.5.3 is compiled in via
duckdb-go-bindings/v2, and the Quack extension is INSTALL’d / LOAD’d
at startup. The same in-process DuckDB serves both sides — HTTP click
writes and Quack remote reads share one writer, which is what DuckDB
needs (single-writer constraint).

flowchart LR
    subgraph readers["Newsletter readers"]
        B["Browser"]
    end

    subgraph laptop["You (laptop)"]
        M["make survey-create
make survey-result
make survey-reset"] D["local duckdb CLI
+ quack extension"] end subgraph rly["Railway"] Edge["HTTPS edge · auto-TLS"] TCP["TCP Proxy · plaintext + token"] subgraph cont["pollmd container · single Go process (CGO)"] HTTP["net/http :8080
vote / landing / result / thanks / style"] QSrv["Quack listener :9494
started by CALL quack_serve"] DB[("libduckdb 1.5.3
single writer · in-process")] Salt["32-byte salt in memory
rotates @ UTC midnight"] end V[("Persistent volume
votes.duckdb")] end B -->|"GET /{id}/{answer}"| Edge Edge --> HTTP HTTP -->|"RecordVote, TallyBySurvey, GetAllowedAnswers"| DB HTTP -.->|"voter.Hash"| Salt M -->|"survey-create writes via Quack"| TCP D -->|"quack_query over HTTP"| TCP TCP --> QSrv QSrv -->|"reads/writes in same process"| DB DB --> V

# One Vote, End to End

flowchart TD
    Click["Browser clicks
https://q.ssp.sh/2026-06-04/awesome"] Click --> Edge["Railway HTTPS edge"] Edge --> H["Go handleSurvey()"] H --> Method{"HTTP method?"} Method -->|"HEAD (Safe Links prefetch)"| OK1["200 · no record"] Method -->|"GET"| Slug{"slug regex
matches id + answer?"} Slug -->|"no"| Err["400 Bad Request"] Slug -->|"yes"| Bot{"User-Agent
looks like a bot?"} Bot -->|"TwitterBot, etc."| OK2["200 · log bot-skip"] Bot -->|"browser"| Reg{"survey_id
in surveys table?"} Reg -->|"no (open mode)"| Hash["voter = sha256(ip + ua + daily_salt + survey_id)[:16]"] Reg -->|"yes and answer allowed"| Hash Reg -->|"yes and answer NOT allowed"| OK3["200 · log answer-reject"] Hash --> Up[("INSERT INTO votes
ON CONFLICT (survey_id, voter)
DO UPDATE — last vote wins")] Up --> Redir["302 → /thanks · log vote"]

# Changelog

Find all changes at GitHub CHANGELOG.md.

# Alternative Names Brainstorming

  • pollmd
  • pulsemd
  • clickmd — describes the actual mechanic (one click, no form)
  • votemd — more generic but clean

# Alternatives

  • Tally is 24$ per month, I wanted one that cost me nothing except hosting

# Other Tiny Markdown Tools

I created Neomd with the same Markdown first philosopy in mind. Check it out, if you like Markdown, TUI and Neovim, to handle emails in the terminal with all keyshortcuts and a HEY screener and GTD workflow included.


Origin: Newsletter, Listmonk