skills/mollie/SKILL.md
Wire Mollie payments (iDEAL-first, Dutch/EU) into a TanStack Start + Cloudflare Workers app, and drive its REST API. Covers test_ vs live_ keys, the hosted-checkout redirect flow (create payment -> redirect to _links.checkout -> webhook -> re-fetch status), why you MUST fetch status from the API in the webhook (the body only carries the payment id), payment statuses, refunds, NL methods (iDEAL/card/Bancontact), and why direct fetch beats the @mollie/api-client SDK on Workers. Use when the user wants to add Mollie, take payments, iDEAL, a checkout, a payment webhook, refunds, or mentions mollie / a test_ key / pfl_ profile.
npx skillsauth add RonanCodes/ronan-skills mollieInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire Mollie into a TanStack Start + Cloudflare Workers app, or call its REST API directly. Mollie is the Dutch/EU payment provider, the default pick for a Netherlands-first product because of iDEAL coverage, with a hosted checkout so you never touch card data.
On Workers, call the REST API directly with fetch (recommended). The @mollie/api-client package switched from Axios to native fetch in v4 (so v4+ is far more edge-friendly than v3), but the README still doesn't officially list Workers, and the surface you need (create + get + refund) is tiny. Direct fetch is zero-dependency and zero runtime-compat risk.
/ro:mollie install # env + a fetch helper module
/ro:mollie add-checkout # create payment -> redirect to hosted checkout
/ro:mollie add-webhook # webhook route that re-fetches status (the security boundary)
/ro:mollie add-refund # refund a payment
/ro:mollie ops # API-ops cheat-sheet (curl), no app wiring
~/.claude/.env (megathon block): MOLLIE_API_KEY (currently test_...), MOLLIE_PROFILE_ID (pfl_...).webhookUrl must be publicly reachable HTTPS, so local dev needs a tunnel or a deployed Worker (Mollie's servers call it; localhost won't work).https://api.mollie.com/v2Authorization: Bearer <key> on every request.test_... (test mode, no real money, simulate any status) vs live_... (real). Keys are profile-scoped, so you don't send profileId on normal calls (only org/OAuth tokens need it).| Purpose | Method + path |
| --- | --- |
| Create payment | POST /v2/payments |
| Get payment | GET /v2/payments/{id} |
| List payments | GET /v2/payments |
| Cancel payment | DELETE /v2/payments/{id} (while cancelable) |
| Create / list / get refund | POST / GET /v2/payments/{id}/refunds (+ /{refundId}) |
Payment ids tr_..., refund ids re_....
redirectUrl (browser returns here) and a webhookUrl (Mollie notifies here). Persist your order ↔ tr_... mapping. Status starts open._links.checkout.href from the response.redirectUrl. Treat this as "user is back", NOT as proof of payment (they may close the tab, or iDEAL settles async).webhookUrl (form-encoded) with only id=tr_.... No status, no JSON.GET /v2/payments/{id} and read status from the API response, then update the order. This is the security boundary: because the webhook carries no status, a forged call can never mark an order paid. Make the handler idempotent (Mollie retries).Statuses: open, pending, authorized, paid (money in), canceled, expired, failed.
# .dev.vars (gitignored)
MOLLIE_API_KEY=test_...
APP_URL=https://souso.app # for building redirect/webhook URLs
wrangler secret put MOLLIE_API_KEY
src/lib/mollie.tsconst BASE = 'https://api.mollie.com/v2'
export interface MolliePayment {
id: string
status: 'open' | 'pending' | 'authorized' | 'paid' | 'canceled' | 'expired' | 'failed'
amount: { currency: string; value: string }
_links: { checkout?: { href: string } }
}
export async function createPayment(
apiKey: string,
p: { amount: string; description: string; redirectUrl: string; webhookUrl: string; method?: string },
): Promise<MolliePayment> {
const res = await fetch(`${BASE}/payments`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: { currency: 'EUR', value: p.amount }, // string, 2 decimals: "10.00"
description: p.description,
redirectUrl: p.redirectUrl,
webhookUrl: p.webhookUrl,
...(p.method ? { method: p.method } : {}),
}),
})
if (!res.ok) throw new Error(`Mollie create failed: ${res.status} ${await res.text()}`)
return res.json()
}
export async function getPayment(apiKey: string, id: string): Promise<MolliePayment> {
const res = await fetch(`${BASE}/payments/${id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!res.ok) throw new Error(`Mollie get failed: ${res.status}`)
return res.json()
}
// src/routes/api/checkout.ts
export const Route = createFileRoute('/api/checkout')({
server: { handlers: { POST: async ({ request }) => {
const { env } = await import('cloudflare:workers')
const { createPayment } = await import('../../lib/mollie')
const { orderId, total } = await request.json()
const payment = await createPayment(env.MOLLIE_API_KEY, {
amount: Number(total).toFixed(2), // "10.00"
description: `Souso order #${orderId}`,
redirectUrl: `${env.APP_URL}/order/${orderId}/return`,
webhookUrl: `${env.APP_URL}/api/mollie/webhook`,
// method: 'ideal', // optional: skip the picker
})
// store orderId -> payment.id, then send the checkout url to the client
return Response.json({ checkoutUrl: payment._links.checkout?.href })
}}},
})
// src/routes/api/mollie/webhook.ts
export const Route = createFileRoute('/api/mollie/webhook')({
server: { handlers: { POST: async ({ request }) => {
const { env } = await import('cloudflare:workers')
const { getPayment } = await import('../../../lib/mollie')
const form = await request.formData()
const id = String(form.get('id') ?? '')
if (!id) return new Response('missing id', { status: 400 })
const payment = await getPayment(env.MOLLIE_API_KEY, id) // source of truth
await updateOrderStatus(id, payment.status) // idempotent
return new Response('ok', { status: 200 }) // 200 fast; Mollie retries on failure
}}},
})
curl -X POST "https://api.mollie.com/v2/payments/tr_xxx/refunds" \
-H "Authorization: Bearer $MOLLIE_API_KEY" -H "Content-Type: application/json" \
-d '{"amount":{"currency":"EUR","value":"10.00"},"description":"Refund order #123"}'
ideal) — dominant NL bank redirect, essential. Pass method:"ideal" (optionally an issuer) to skip the picker.method to show Mollie's hosted picker (lists every method enabled on the profile). Activate methods in the dashboard before they appear live.amount.value is a string with exactly 2 decimals ("10.00"), not a number. 10, 10.0, "10" are rejected. Use total.toFixed(2).test_ only makes test payments (force statuses in the test UI); live_ needs an approved account + activated methods. Don't mix.webhookUrl must be public HTTPS. Tunnel (cloudflared/ngrok) or deployed Worker for local dev.GET /v2/payments/{id}. Webhook is idempotent (Mollie retries, may fire multiple times).compatibility_flags=["nodejs_compat"] and test), but direct fetch is the safe default.MOLLIE_API_KEY=test_... # server only; live_... in prod
MOLLIE_PROFILE_ID=pfl_... # informational; not sent on profile-scoped key calls
APP_URL=https://... # to build redirect + webhook URLs
Server-side only; the key is full payment access. The webhook re-fetch is mandatory and is what makes the flow forgery-proof, never derive paid-status from the request. Keep the live key out of the repo (Wrangler secret).
@mollie/api-client (v4+ fetch-based). Sibling payments skill: /ro:stripetesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".