Is your website overrun by bots, grifters, rogue agents, and other assorted scum-of-the-earth?
Try replacing your CAPTCHAs with charity donations!
Proof-of-donation is a self-hosted alternative to CAPTCHA. Instead of having your new users solve a puzzle, have them donate to a supported charity and upload their email receipt. The proof-of-donation server uses email signatures (DKIM) to verify the authenticity of the receipt ("did the user actually donate X dollars somewhere?") and return a pass/no-pass.
Disclaimer: This project is new and not currently used anywhere in production. If you'd like to try it out in the wild, happy to help with setup :)
.eml file), instead of
solving a CAPTCHA.POST /verify to the server from your backend. It will verify the receipt and return pass/no-pass.How does it verify a receipt? Most modern email senders cryptographically sign emails sent by them (using a security standard called DKIM). We use this signature to verify that the email is in fact from the given sender and that it has not been tampered with. Once verified, a plugin parses the needed info like donation amount.
The server can work with any charity that emails a donation receipt (without any direct integration needed from their end). Plugins can be made to enable whatever charities you wish to support.
The server itself is meant to be simple to host. It is entirely stateless, with no database or memory of past requests.
Try out the demo. It will give you a sense of the proposed user flow.
npm install
npm run demo
Run the server with node:
npm install
cp .env.example .env # edit as needed
npm run build
node --env-file=.env dist/index.js
Run the server in development mode (with auto-reload):
npm install
npm run dev
docker run -p 8787:8787 ghcr.io/mgriley/proof-of-donation:latest
Or build from source:
docker build -t proof-of-donation .
docker run -p 8787:8787 proof-of-donation
:latest tracks master (no versioned releases yet).
POST /verifyRequest body: the raw .eml message bytes. Query parameters:
| Param | Required | Description |
|---|---|---|
minAmount | yes | Minimum donation amount required, in the receipt's major currency unit. |
maxAgeHours | yes | How recent the donation must be, in hours. |
currency | no | Required ISO 4217 currency code (e.g. USD). If omitted, any currency the plugin reports is accepted. |
No claimedEmail param — the server doesn't bind identity for you. It reports donorEmail;
compare it yourself. See Integration Guide.
Always HTTP 200 — check valid. Other fields are present whenever a receipt was parsed,
pass or fail:
{
"valid": true,
"pluginId": "salvation-army",
"charity": "The Salvation Army",
"amount": 10,
"currency": "USD",
"donatedAt": "2026-09-12T07:00:00.000Z",
"donorEmail": "donor@example.com",
"receiptId": "44d1f3ede445ee69333fffd826e8ac646d6f5c9f615ce8aa8945899e054471a2"
}
{
"valid": false,
"reason": "This donation (5 USD) is below the required minimum of 10 USD.",
"pluginId": "salvation-army",
"charity": "The Salvation Army",
"amount": 5,
"currency": "USD",
"donatedAt": "2026-09-12T07:00:00.000Z",
"donorEmail": "donor@example.com",
"receiptId": "44d1f3ede445ee69333fffd826e8ac646d6f5c9f615ce8aa8945899e054471a2"
}
curl -X POST "http://localhost:8787/verify?minAmount=5&maxAgeHours=48" \
--data-binary @receipt.eml
GET /charitiesCharities to show a donor before they've donated (e.g. a donation picker):
{ "charities": [{ "charityName", "description", "supportedCurrencies", "donateLink" }] }.
curl http://localhost:8787/charities
{
"charities": [
{
"charityName": "The Salvation Army",
"description": "The Salvation Army provides food, shelter, and other social services to people.",
"supportedCurrencies": ["USD"],
"donateLink": "https://www.salvationarmyusa.org/ways-to-give/"
}
]
}
GET /pluginsWhat this instance can verify: { "plugins": [{ "id", "name", "trustedDkimDomains" }] }.
GET /healthLiveness check: { "ok": true }.
See .env.example. Configured via environment variables (Node's built-in --env-file works).
salvation-army — The Salvation Army, via GoFundMe Charity.More coming — each one needs a real receipt sample to build correctly (see Adding a plugin).
A plugin declares which DKIM domain(s) it trusts and how to read a receipt from a message
signed by one of them. parse() only ever runs on a message that already passed DKIM
verification.
interface ReceiptPlugin {
id: string; // unique, stable, e.g. "salvation-army"
name: string;
trustedDkimDomains: string[]; // DKIM `d=` domains this plugin will accept
parse(message: VerifiedMessage): DonationReceipt | null; // null = "not a match", don't throw
}
Two ways to get one:
.js, advanced) — for a template a regex can't express (e.g. amount only
in a PDF). Runs as trusted code with full Node.js access.{
"id": "salvation-army",
"name": "The Salvation Army (via GoFundMe Charity)",
"charityName": "The Salvation Army",
"description": "The Salvation Army provides food, shelter, and other social services to people in need.",
"supportedCurrencies": ["USD"],
"donateLink": "https://www.salvationarmyusa.org/ways-to-give/",
"currency": "USD",
"trustedDkimDomains": ["prosend.gofundme.com"],
"trustedFromAddress": "info@the-salvation-army-national-corp.prosend.gofundme.com",
"subjectPattern": "thank you|donation|receipt",
"amountPattern": "donation amount\\s*\\$?\\s*([\\d,]+\\.\\d{2})",
"datePattern": "donation date\\s*([A-Za-z]{3,9}\\.?\\s+\\d{1,2},?\\s+\\d{4})"
}
| Field | Required | Description |
|---|---|---|
id | yes | Unique, stable, lowercase-with-hyphens. |
name | yes | Human-readable name for logs/docs. |
charityName | yes | Reported in a successful result, and shown via GET /charities. |
description | yes | What the charity does, shown via GET /charities. |
supportedCurrencies | yes | Currencies this charity accepts (display-only — see note below). |
donateLink | yes | https:// URL where a user can go make a donation. |
currency | yes | Currency this template's regex extracts (parsing detail — see note below). |
trustedDkimDomains | yes | Array of DKIM d= domains this plugin trusts. |
trustedFromAddress | no | Exact From: address required. See below — needed for shared platforms. |
subjectPattern | yes | Regex tested against the subject (case-insensitive). No capture group needed. |
amountPattern | yes | Regex with one capture group: the donation amount, e.g. "12.34". |
datePattern | yes | Regex with one capture group: a Date-parseable date string. |
currency is a parsing detail (what this template extracts); supportedCurrencies is
display metadata (what the charity accepts overall). They can differ.
A plugin file is a JSON array of these objects — one or many per file. A bad entry fails loudly at startup, naming the file and field.
Many charities send receipts via a shared platform (GoFundMe Charity, PayPal Giving Fund,
Classy, Stripe), not their own domain — a passing signature only proves some campaign on
that platform sent it. Set trustedFromAddress to the exact per-charity address to narrow
it down (confirm From is in the signature's h= list first). Omit it only when the domain
itself is charity-specific. See plugins/salvation-army.json for a worked example.
.eml, full headers). Never commit real samples — they
contain personal data (see example_receipts/ in .gitignore).d= domain and h= signed headers to see what you can trust.plugins/, or a directory listed in
PLUGIN_DIRS (see .env.example) — picked up automatically, no code changes.src/plugins/regex-plugin.test.ts).For a template that can't be expressed as regexes, implement ReceiptPlugin directly as a
.js file instead — PLUGIN_DIRS loads both kinds recursively, the same way.
DISABLE_BUNDLED_PLUGINS=true skips the bundled plugins/ directory, for a fully custom
charity list.
Host this internally, not publicly. /verify has no built-in auth or rate limiting --
it's meant to be called from your own backend, not exposed directly to the internet or to a
user's browser.
The server is stateless — no database, no memory between requests. That means your website's server is responsible for:
donorEmail against an email address you've already validated for the
account being created. The server has no idea which account this is for, so it can't do
this check for you.receiptId, if you want replay protection — e.g. to stop a user from reusing
the same receipt over and over to create many accounts with the same email. If that
matters to you, save receiptId alongside the account you create, and reject any signup
that reuses one you've already seen.Similar to CAPTCHA, proof-of-donation cannot reasonably stop a determined attacker. It can, however, increase the cost of creating hundreds or thousands of low-effort bot accounts. This can make a substantial difference for some websites and forums.
CAD receipt is rejected outright if you require USD.Node.js >= 20.18.1. No database, no native modules to compile.
25 commits
TypeScript
99.4%
Is your website overrun by bots, grifters, rogue agents, and other assorted scum-of-the-earth?
Try replacing your CAPTCHAs with charity donations!
Proof-of-donation is a self-hosted alternative to CAPTCHA. Instead of having your new users solve a puzzle, have them donate to a supported charity and upload their email receipt. The proof-of-donation server uses email signatures (DKIM) to verify the authenticity of the receipt ("did the user actually donate X dollars somewhere?") and return a pass/no-pass.
Disclaimer: This project is new and not currently used anywhere in production. If you'd like to try it out in the wild, happy to help with setup :)
.eml file), instead of
solving a CAPTCHA.POST /verify to the server from your backend. It will verify the receipt and return pass/no-pass.How does it verify a receipt? Most modern email senders cryptographically sign emails sent by them (using a security standard called DKIM). We use this signature to verify that the email is in fact from the given sender and that it has not been tampered with. Once verified, a plugin parses the needed info like donation amount.
The server can work with any charity that emails a donation receipt (without any direct integration needed from their end). Plugins can be made to enable whatever charities you wish to support.
The server itself is meant to be simple to host. It is entirely stateless, with no database or memory of past requests.
Try out the demo. It will give you a sense of the proposed user flow.
npm install
npm run demo
Run the server with node:
npm install
cp .env.example .env # edit as needed
npm run build
node --env-file=.env dist/index.js
Run the server in development mode (with auto-reload):
npm install
npm run dev
docker run -p 8787:8787 ghcr.io/mgriley/proof-of-donation:latest
Or build from source:
docker build -t proof-of-donation .
docker run -p 8787:8787 proof-of-donation
:latest tracks master (no versioned releases yet).
POST /verifyRequest body: the raw .eml message bytes. Query parameters:
| Param | Required | Description |
|---|---|---|
minAmount | yes | Minimum donation amount required, in the receipt's major currency unit. |
maxAgeHours | yes | How recent the donation must be, in hours. |
currency | no | Required ISO 4217 currency code (e.g. USD). If omitted, any currency the plugin reports is accepted. |
No claimedEmail param — the server doesn't bind identity for you. It reports donorEmail;
compare it yourself. See Integration Guide.
Always HTTP 200 — check valid. Other fields are present whenever a receipt was parsed,
pass or fail:
{
"valid": true,
"pluginId": "salvation-army",
"charity": "The Salvation Army",
"amount": 10,
"currency": "USD",
"donatedAt": "2026-09-12T07:00:00.000Z",
"donorEmail": "donor@example.com",
"receiptId": "44d1f3ede445ee69333fffd826e8ac646d6f5c9f615ce8aa8945899e054471a2"
}
{
"valid": false,
"reason": "This donation (5 USD) is below the required minimum of 10 USD.",
"pluginId": "salvation-army",
"charity": "The Salvation Army",
"amount": 5,
"currency": "USD",
"donatedAt": "2026-09-12T07:00:00.000Z",
"donorEmail": "donor@example.com",
"receiptId": "44d1f3ede445ee69333fffd826e8ac646d6f5c9f615ce8aa8945899e054471a2"
}
curl -X POST "http://localhost:8787/verify?minAmount=5&maxAgeHours=48" \
--data-binary @receipt.eml
GET /charitiesCharities to show a donor before they've donated (e.g. a donation picker):
{ "charities": [{ "charityName", "description", "supportedCurrencies", "donateLink" }] }.
curl http://localhost:8787/charities
{
"charities": [
{
"charityName": "The Salvation Army",
"description": "The Salvation Army provides food, shelter, and other social services to people.",
"supportedCurrencies": ["USD"],
"donateLink": "https://www.salvationarmyusa.org/ways-to-give/"
}
]
}
GET /pluginsWhat this instance can verify: { "plugins": [{ "id", "name", "trustedDkimDomains" }] }.
GET /healthLiveness check: { "ok": true }.
See .env.example. Configured via environment variables (Node's built-in --env-file works).
salvation-army — The Salvation Army, via GoFundMe Charity.More coming — each one needs a real receipt sample to build correctly (see Adding a plugin).
A plugin declares which DKIM domain(s) it trusts and how to read a receipt from a message
signed by one of them. parse() only ever runs on a message that already passed DKIM
verification.
interface ReceiptPlugin {
id: string; // unique, stable, e.g. "salvation-army"
name: string;
trustedDkimDomains: string[]; // DKIM `d=` domains this plugin will accept
parse(message: VerifiedMessage): DonationReceipt | null; // null = "not a match", don't throw
}
Two ways to get one:
.js, advanced) — for a template a regex can't express (e.g. amount only
in a PDF). Runs as trusted code with full Node.js access.{
"id": "salvation-army",
"name": "The Salvation Army (via GoFundMe Charity)",
"charityName": "The Salvation Army",
"description": "The Salvation Army provides food, shelter, and other social services to people in need.",
"supportedCurrencies": ["USD"],
"donateLink": "https://www.salvationarmyusa.org/ways-to-give/",
"currency": "USD",
"trustedDkimDomains": ["prosend.gofundme.com"],
"trustedFromAddress": "info@the-salvation-army-national-corp.prosend.gofundme.com",
"subjectPattern": "thank you|donation|receipt",
"amountPattern": "donation amount\\s*\\$?\\s*([\\d,]+\\.\\d{2})",
"datePattern": "donation date\\s*([A-Za-z]{3,9}\\.?\\s+\\d{1,2},?\\s+\\d{4})"
}
| Field | Required | Description |
|---|---|---|
id | yes | Unique, stable, lowercase-with-hyphens. |
name | yes | Human-readable name for logs/docs. |
charityName | yes | Reported in a successful result, and shown via GET /charities. |
description | yes | What the charity does, shown via GET /charities. |
supportedCurrencies | yes | Currencies this charity accepts (display-only — see note below). |
donateLink | yes | https:// URL where a user can go make a donation. |
currency | yes | Currency this template's regex extracts (parsing detail — see note below). |
trustedDkimDomains | yes | Array of DKIM d= domains this plugin trusts. |
trustedFromAddress | no | Exact From: address required. See below — needed for shared platforms. |
subjectPattern | yes | Regex tested against the subject (case-insensitive). No capture group needed. |
amountPattern | yes | Regex with one capture group: the donation amount, e.g. "12.34". |
datePattern | yes | Regex with one capture group: a Date-parseable date string. |
currency is a parsing detail (what this template extracts); supportedCurrencies is
display metadata (what the charity accepts overall). They can differ.
A plugin file is a JSON array of these objects — one or many per file. A bad entry fails loudly at startup, naming the file and field.
Many charities send receipts via a shared platform (GoFundMe Charity, PayPal Giving Fund,
Classy, Stripe), not their own domain — a passing signature only proves some campaign on
that platform sent it. Set trustedFromAddress to the exact per-charity address to narrow
it down (confirm From is in the signature's h= list first). Omit it only when the domain
itself is charity-specific. See plugins/salvation-army.json for a worked example.
.eml, full headers). Never commit real samples — they
contain personal data (see example_receipts/ in .gitignore).d= domain and h= signed headers to see what you can trust.plugins/, or a directory listed in
PLUGIN_DIRS (see .env.example) — picked up automatically, no code changes.src/plugins/regex-plugin.test.ts).For a template that can't be expressed as regexes, implement ReceiptPlugin directly as a
.js file instead — PLUGIN_DIRS loads both kinds recursively, the same way.
DISABLE_BUNDLED_PLUGINS=true skips the bundled plugins/ directory, for a fully custom
charity list.
Host this internally, not publicly. /verify has no built-in auth or rate limiting --
it's meant to be called from your own backend, not exposed directly to the internet or to a
user's browser.
The server is stateless — no database, no memory between requests. That means your website's server is responsible for:
donorEmail against an email address you've already validated for the
account being created. The server has no idea which account this is for, so it can't do
this check for you.receiptId, if you want replay protection — e.g. to stop a user from reusing
the same receipt over and over to create many accounts with the same email. If that
matters to you, save receiptId alongside the account you create, and reject any signup
that reuses one you've already seen.Similar to CAPTCHA, proof-of-donation cannot reasonably stop a determined attacker. It can, however, increase the cost of creating hundreds or thousands of low-effort bot accounts. This can make a substantial difference for some websites and forums.
CAD receipt is rejected outright if you require USD.Node.js >= 20.18.1. No database, no native modules to compile.
25 commits
TypeScript
99.4%