Skip to content

Commit e5f4e89

Browse files
feat: add Ko-fi webhook support (#125)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 4af0014 commit e5f4e89

8 files changed

Lines changed: 658 additions & 1 deletion

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Supports:
1212
- [**Afdian**](https://afdian.com/)
1313
- [**Polar**](https://polar.sh/)
1414
- [**Liberapay**](https://liberapay.com/)
15+
- [**Ko-fi**](https://ko-fi.com/)
1516

1617
## Usage
1718

@@ -59,6 +60,12 @@ SPONSORKIT_POLAR_ORGANIZATION=
5960
; Liberapay provider.
6061
; The name of the profile.
6162
SPONSORKIT_LIBERAPAY_LOGIN=
63+
64+
; Ko-fi provider.
65+
; Copy the verification token from https://ko-fi.com/manage/webhooks
66+
SPONSORKIT_KOFI_VERIFICATION_TOKEN=
67+
; Optional event store path populated by `sponsorkit kofi-webhook`.
68+
SPONSORKIT_KOFI_DATA_FILE=./sponsorkit/kofi-events.json
6269
```
6370

6471
> Only one provider is required to be configured.
@@ -104,6 +111,9 @@ export default defineConfig({
104111
liberapay: {
105112
// ...
106113
},
114+
kofi: {
115+
// ...
116+
},
107117

108118
// Rendering configs
109119
width: 800,
@@ -160,6 +170,31 @@ const sponsors = await fetchSponsors({
160170

161171
Check the type definition or source code for more utils available.
162172

173+
### Ko-fi Webhooks
174+
175+
Ko-fi provides payment webhooks instead of an API for listing sponsors. Start the
176+
SponsorKit receiver:
177+
178+
```bash
179+
npx sponsorkit kofi-webhook
180+
```
181+
182+
Expose `http://127.0.0.1:3456/kofi` through an HTTPS tunnel, then set that public
183+
URL on the [Ko-fi webhooks page](https://ko-fi.com/manage/webhooks). SponsorKit
184+
verifies the webhook token, removes the token and email before persistence,
185+
deduplicates retries by `message_id`, and stores events in
186+
`./sponsorkit/kofi-events.json`.
187+
188+
After Ko-fi sends a payment or test payment, generate the sponsor output normally:
189+
190+
```bash
191+
npx sponsorkit --force
192+
```
193+
194+
Ko-fi does not send an event when a membership ends. SponsorKit therefore treats
195+
subscription payments as active for 35 days by default. Configure
196+
`kofi.subscriptionEffectivity` to change this window.
197+
163198
### Renderers
164199

165200
We provide two renderers built-in:

src/cli.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,40 @@
22
import type { SponsorkitConfig } from './types.ts'
33
import cac from 'cac'
44
import pkg from '../package.json' with { type: 'json' }
5+
import { loadConfig } from './configs/index.ts'
6+
import { DEFAULT_KOFI_DATA_FILE, startKofiWebhookServer } from './providers/kofi.ts'
57
import { run } from './run.ts'
68

79
const RE_FILTER = /([<>=]+)(\d+)/
810
const cli = cac('sponsors-svg')
911
.version(pkg.version)
1012
.help()
1113

14+
cli
15+
.command('kofi-webhook', 'Receive and store Ko-fi payment webhooks')
16+
.option('--host <host>', 'Host to listen on', { default: '127.0.0.1' })
17+
.option('--port <port>', 'Port to listen on', { default: 3456 })
18+
.option('--path <path>', 'Webhook path', { default: '/kofi' })
19+
.option('--data-file <file>', 'Ko-fi event store')
20+
.action(async (options) => {
21+
const config = await loadConfig()
22+
const verificationToken = config.kofi?.verificationToken
23+
if (!verificationToken) {
24+
throw new Error('Ko-fi verification token is required')
25+
}
26+
const dataFile = options.dataFile || config.kofi?.dataFile || DEFAULT_KOFI_DATA_FILE
27+
const port = Number.parseInt(options.port)
28+
await startKofiWebhookServer({
29+
verificationToken,
30+
dataFile,
31+
host: options.host,
32+
port,
33+
path: options.path,
34+
})
35+
console.log(`[sponsorkit] Ko-fi webhook listening on http://${options.host}:${port}${options.path}`)
36+
console.log(`[sponsorkit] Storing sanitized events in ${resolveDisplayPath(dataFile)}`)
37+
})
38+
1239
cli
1340
.command('[outputDir]', 'Generate sponsors SVG')
1441
.option('--width, -w <width>', 'SVG width', { default: 800 })
@@ -50,3 +77,7 @@ function createFilterFromString(template: string): SponsorkitConfig['filter'] {
5077
return s => s.monthlyDollars >= num
5178
throw new Error(`Unable to parse filter template ${template}`)
5279
}
80+
81+
function resolveDisplayPath(path: string) {
82+
return path.replaceAll('\\', '/')
83+
}

src/configs/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export function loadEnv(): Partial<SponsorkitConfig> {
4343
liberapay: {
4444
login: process.env.SPONSORKIT_LIBERAPAY_LOGIN || process.env.LIBERAPAY_LOGIN,
4545
},
46+
kofi: {
47+
verificationToken: process.env.SPONSORKIT_KOFI_VERIFICATION_TOKEN || process.env.KOFI_VERIFICATION_TOKEN,
48+
dataFile: process.env.SPONSORKIT_KOFI_DATA_FILE,
49+
},
4650
outputDir: process.env.SPONSORKIT_DIR,
4751
}
4852

src/configs/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export async function loadConfig(inlineConfig: SponsorkitConfig = {}): Promise<R
5656
...config.afdian,
5757
...inlineConfig.afdian,
5858
},
59+
kofi: {
60+
...env.kofi,
61+
...config.kofi,
62+
...inlineConfig.kofi,
63+
},
5964
} as Required<SponsorkitConfig>
6065

6166
if (!['sponsors', 'sponsees'].includes(resolved.mode))

src/providers/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { Provider, ProviderName, SponsorkitConfig } from '../types.ts'
22
import { AfdianProvider } from './afdian.ts'
33
import { GitHubProvider } from './github.ts'
4+
import { KofiProvider } from './kofi.ts'
45
import { LiberapayProvider } from './liberapay.ts'
56
import { OpenCollectiveProvider } from './opencollective.ts'
67
import { PatreonProvider } from './patreon.ts'
78
import { PolarProvider } from './polar.ts'
89

910
export * from './github.ts'
11+
export * from './kofi.ts'
1012

1113
export const ProvidersMap = {
1214
github: GitHubProvider,
@@ -15,6 +17,7 @@ export const ProvidersMap = {
1517
afdian: AfdianProvider,
1618
polar: PolarProvider,
1719
liberapay: LiberapayProvider,
20+
kofi: KofiProvider,
1821
}
1922

2023
export function guessProviders(config: SponsorkitConfig) {
@@ -37,6 +40,9 @@ export function guessProviders(config: SponsorkitConfig) {
3740
if (config.liberapay && config.liberapay.login)
3841
items.push('liberapay')
3942

43+
if (config.kofi && (config.kofi.verificationToken || config.kofi.dataFile))
44+
items.push('kofi')
45+
4046
// fallback
4147
if (!items.length)
4248
items.push('github')

src/providers/kofi.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { AddressInfo } from 'node:net'
2+
import { mkdtemp, readFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import {
7+
fetchKofiSponsors,
8+
parseKofiWebhookBody,
9+
startKofiWebhookServer,
10+
storeKofiEvent,
11+
} from './kofi.ts'
12+
13+
const servers: Awaited<ReturnType<typeof startKofiWebhookServer>>[] = []
14+
15+
afterEach(async () => {
16+
await Promise.all(servers.splice(0).map(server => new Promise<void>((resolve, reject) => {
17+
server.close(error => error ? reject(error) : resolve())
18+
})))
19+
})
20+
21+
describe('ko-fi provider', () => {
22+
it('verifies and sanitizes webhook payloads', () => {
23+
const event = parseKofiWebhookBody(createBody(), 'correct-token')
24+
25+
expect(event).toMatchObject({
26+
messageId: 'message-1',
27+
timestamp: '2026-07-27T05:00:00.000Z',
28+
type: 'Subscription',
29+
isPublic: true,
30+
fromName: 'Ada',
31+
amount: 5,
32+
currency: 'USD',
33+
isSubscriptionPayment: true,
34+
tierName: 'Gold',
35+
})
36+
expect(event).not.toHaveProperty('verification_token')
37+
expect(event).not.toHaveProperty('email')
38+
})
39+
40+
it('rejects a webhook with the wrong verification token', () => {
41+
expect(() => parseKofiWebhookBody(createBody(), 'wrong-token'))
42+
.toThrow('Invalid Ko-fi verification token')
43+
})
44+
45+
it('scrubs the identity of private payments', () => {
46+
const event = parseKofiWebhookBody(createBody({
47+
is_public: false,
48+
from_name: 'Private Name',
49+
email: 'private@example.com',
50+
}), 'correct-token')
51+
52+
expect(event.fromName).toBe('Private Sponsor')
53+
expect(event.isPublic).toBe(false)
54+
})
55+
56+
it('groups private subscription renewals without exposing their identity', async () => {
57+
const directory = await mkdtemp(join(tmpdir(), 'sponsorkit-kofi-private-'))
58+
const dataFile = join(directory, 'events.json')
59+
const firstPayment = parseKofiWebhookBody(createBody({
60+
message_id: 'private-payment-1',
61+
timestamp: '2026-07-01T00:00:00Z',
62+
is_public: false,
63+
from_name: 'Private Name',
64+
email: 'Private@Example.com',
65+
}), 'correct-token')
66+
const renewal = parseKofiWebhookBody(createBody({
67+
message_id: 'private-payment-2',
68+
timestamp: '2026-07-20T00:00:00Z',
69+
is_public: false,
70+
from_name: 'Private Name',
71+
email: 'private@example.com',
72+
is_first_subscription_payment: false,
73+
}), 'correct-token')
74+
75+
expect(firstPayment.sponsorKey).toBe(renewal.sponsorKey)
76+
await storeKofiEvent(firstPayment, dataFile)
77+
await storeKofiEvent(renewal, dataFile)
78+
79+
const sponsors = await fetchKofiSponsors(
80+
{ dataFile },
81+
Date.parse('2026-07-27T00:00:00Z'),
82+
)
83+
expect(sponsors).toHaveLength(1)
84+
expect(sponsors[0]).toMatchObject({
85+
monthlyDollars: 5,
86+
privacyLevel: 'PRIVATE',
87+
sponsor: { name: 'Private Sponsor' },
88+
})
89+
})
90+
91+
it('does not merge unrelated anonymous payments', () => {
92+
const firstPayment = parseKofiWebhookBody(createBody({
93+
message_id: 'anonymous-payment-1',
94+
type: 'Tip',
95+
from_name: '',
96+
email: '',
97+
is_subscription_payment: false,
98+
}), 'correct-token')
99+
const secondPayment = parseKofiWebhookBody(createBody({
100+
message_id: 'anonymous-payment-2',
101+
type: 'Tip',
102+
from_name: '',
103+
email: '',
104+
is_subscription_payment: false,
105+
}), 'correct-token')
106+
107+
expect(firstPayment.fromName).toBe('Anonymous')
108+
expect(firstPayment.sponsorKey).not.toBe(secondPayment.sponsorKey)
109+
})
110+
111+
it('keeps public and private payments from the same identity separate', () => {
112+
const publicPayment = parseKofiWebhookBody(createBody(), 'correct-token')
113+
const privatePayment = parseKofiWebhookBody(createBody({
114+
message_id: 'private-payment',
115+
is_public: false,
116+
}), 'correct-token')
117+
118+
expect(publicPayment.sponsorKey).not.toBe(privatePayment.sponsorKey)
119+
})
120+
121+
it('deduplicates retries and aggregates recent payments', async () => {
122+
const directory = await mkdtemp(join(tmpdir(), 'sponsorkit-kofi-'))
123+
const dataFile = join(directory, 'events.json')
124+
const subscription = parseKofiWebhookBody(createBody({
125+
timestamp: '2026-07-01T00:00:00Z',
126+
}), 'correct-token')
127+
const tip = parseKofiWebhookBody(createBody({
128+
message_id: 'message-2',
129+
timestamp: '2026-07-05T00:00:00Z',
130+
type: 'Tip',
131+
amount: '2',
132+
is_subscription_payment: false,
133+
is_first_subscription_payment: false,
134+
tier_name: undefined,
135+
}), 'correct-token')
136+
137+
expect(await storeKofiEvent(subscription, dataFile)).toBe(true)
138+
expect(await storeKofiEvent(subscription, dataFile)).toBe(false)
139+
expect(await storeKofiEvent(tip, dataFile)).toBe(true)
140+
141+
const sponsors = await fetchKofiSponsors(
142+
{ dataFile },
143+
Date.parse('2026-07-20T00:00:00Z'),
144+
)
145+
expect(sponsors).toHaveLength(1)
146+
expect(sponsors[0]).toMatchObject({
147+
monthlyDollars: 7,
148+
privacyLevel: 'PUBLIC',
149+
isOneTime: false,
150+
sponsor: {
151+
name: 'Ada',
152+
avatarUrl: '',
153+
},
154+
})
155+
})
156+
157+
it('receives a live-shaped HTTP form post and persists it', async () => {
158+
const directory = await mkdtemp(join(tmpdir(), 'sponsorkit-kofi-http-'))
159+
const dataFile = join(directory, 'events.json')
160+
const server = await startKofiWebhookServer({
161+
verificationToken: 'correct-token',
162+
dataFile,
163+
host: '127.0.0.1',
164+
port: 0,
165+
})
166+
servers.push(server)
167+
const { port } = server.address() as AddressInfo
168+
169+
const response = await fetch(`http://127.0.0.1:${port}/kofi`, {
170+
method: 'POST',
171+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
172+
body: createBody(),
173+
})
174+
175+
expect(response.status).toBe(200)
176+
expect(await response.json()).toEqual({ ok: true, stored: true })
177+
const stored = JSON.parse(await readFile(dataFile, 'utf8'))
178+
expect(stored.events).toHaveLength(1)
179+
expect(stored.events[0]).not.toHaveProperty('verification_token')
180+
})
181+
182+
it('provides a browser-friendly health check', async () => {
183+
const server = await startKofiWebhookServer({
184+
verificationToken: 'correct-token',
185+
host: '127.0.0.1',
186+
port: 0,
187+
})
188+
servers.push(server)
189+
const { port } = server.address() as AddressInfo
190+
191+
const response = await fetch(`http://127.0.0.1:${port}/kofi`)
192+
193+
expect(response.status).toBe(200)
194+
await expect(response.json()).resolves.toMatchObject({
195+
ok: true,
196+
message: expect.stringContaining('receiver is ready'),
197+
})
198+
})
199+
})
200+
201+
function createBody(overrides: Record<string, unknown> = {}) {
202+
const payload = {
203+
verification_token: 'correct-token',
204+
message_id: 'message-1',
205+
timestamp: '2026-07-27T05:00:00Z',
206+
type: 'Subscription',
207+
is_public: true,
208+
from_name: 'Ada',
209+
message: 'Thank you',
210+
amount: '5',
211+
url: 'https://ko-fi.com/example',
212+
email: 'ada@example.com',
213+
currency: 'USD',
214+
is_subscription_payment: true,
215+
is_first_subscription_payment: true,
216+
tier_name: 'Gold',
217+
kofi_transaction_id: 'transaction-1',
218+
...overrides,
219+
}
220+
return new URLSearchParams({ data: JSON.stringify(payload) }).toString()
221+
}

0 commit comments

Comments
 (0)