Search docs

Jump between documentation pages.

Accept payments with Mollie in DaloyJS

Mollie is the dominant European payment platform — strong on iDEAL, Bancontact, SEPA, Klarna, and a deep catalogue of local methods alongside cards and wallets. This guide uses the official mollie-api-typescript SDK (v1.8.x, released May 2026) from a DaloyJS server, with the new SignatureValidator helper for signed webhooks.

What you should know up front

  • Right package, please. The new SDK is mollie-api-typescript (Speakeasy-generated, Fetch-based, tree-shakable, edge-runtime friendly). The older @mollie/api-client still works but is the previous generation — new projects should use the TypeScript-first one.
  • It's a redirect flow. You create a payment, Mollie returns a _links.checkout.href, you redirect the customer there. They come back to your redirectUrl (browser) and your webhookUrl gets POSTed (server). The redirect is a UX signal only — the webhook is the source of truth.
  • Webhooks are signed now. Mollie sends X-Mollie-Signature: sha256=... on signed endpoints. Verify with SignatureValidator; treat "no signature header" as a legacy webhook (older subscriptions don't sign).
  • Amounts are decimal strings. Unlike most providers, Mollie wants { currency: "EUR", value: "10.00" } — a string with exactly two decimals for EUR. Pass 1000as a number and you'll get a 422.
  • Test vs live is the API key. Keys are prefixed test_ or live_; there's no separate environment flag for normal API-key auth. testmode: true is only needed for organisation-level OAuth tokens.

1. Provision

  1. Sign up at my.mollie.com and create a profile.
  2. Generate a test API key (Dashboard → Developers → API keys). It starts with test_.
  3. In Developers → Webhooks, create a webhook subscription pointing at your DaloyJS endpoint. Save the signing secret— you'll only see it once.
  4. Enable the payment methods you want under Settings → Website profile → Payment methods. iDEAL and Bancontact need explicit activation.

2. Install

ts
pnpm add mollie-api-typescript

3. Environment variables

ts
# .env
MOLLIE_API_KEY=test_replace_me              # or live_...
MOLLIE_WEBHOOK_SECRET=whsec_replace_me      # from Developers → Webhooks
APP_URL=https://your-app.example.com

4. Plugin

ts
// src/plugins/mollie.ts
import { Client, SignatureValidator, InvalidSignatureException } from "mollie-api-typescript";
import type { App } from "@daloyjs/core";

const mollie = new Client({
  security: { apiKey: process.env.MOLLIE_API_KEY! },
});

const validator = new SignatureValidator(process.env.MOLLIE_WEBHOOK_SECRET!);

export interface MollieClient {
  raw: Client;
  createPayment(input: {
    amount: { currency: string; value: string };  // value is a string!
    description: string;
    redirectUrl: string;
    method?: string[];                            // e.g. ["ideal", "creditcard"]
    metadata?: Record<string, unknown>;
    idempotencyKey: string;
  }): Promise<{ id: string; checkoutUrl: string; status: string }>;

  getPayment(id: string): Promise<{ id: string; status: string; amount: { currency: string; value: string } }>;

  verifyWebhook(rawBody: string, signatureHeader: string | null): Promise<"valid" | "legacy" | "invalid">;
}

export const molliePlugin = {
  name: "mollie",
  register(app: App) {
    const client: MollieClient = {
      raw: mollie,

      async createPayment({ amount, description, redirectUrl, method, metadata, idempotencyKey }) {
        const res = await mollie.payments.create({
          idempotencyKey,
          paymentRequest: {
            amount,
            description,
            redirectUrl,
            webhookUrl: `${process.env.APP_URL}/webhooks/mollie`,
            ...(method ? { method } : {}),
            ...(metadata ? { metadata } : {}),
          },
        });
        return {
          id: res.id!,
          status: res.status!,
          checkoutUrl: res.links?.checkout?.href ?? "",
        };
      },

      async getPayment(id) {
        const res = await mollie.payments.get({ paymentId: id });
        return {
          id: res.id!,
          status: res.status!,
          amount: res.amount!,
        };
      },

      async verifyWebhook(rawBody, signatureHeader) {
        try {
          const verified = await validator.validatePayload(rawBody, signatureHeader ?? undefined);
          return verified ? "valid" : "legacy";
        } catch (error) {
          if (error instanceof InvalidSignatureException) return "invalid";
          throw error;
        }
      },
    };

    app.decorate("mollie", client);
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    mollie: MollieClient;
  }
}

SignatureValidator uses HMAC-SHA256 over the raw request body. The raw body is non-negotiable — re-serialising parsed JSON will reorder fields and break the signature.

5. Create a payment

ts
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { molliePlugin } from "./plugins/mollie";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 30 }));
app.register(molliePlugin);

app.route({
  method: "POST",
  path: "/checkout/mollie",
  operationId: "createMolliePayment",
  request: {
    body: z.object({
      orderId: z.string().min(1).max(80),
      amount: z.object({
        currency: z.string().length(3),
        value: z.string().regex(/^\d+\.\d{2}$/),  // "10.00"
      }),
      description: z.string().min(1).max(255),
      method: z.array(z.string()).optional(),
    }),
  },
  responses: {
    201: {
      description: "payment created",
      body: z.object({
        paymentId: z.string(),
        checkoutUrl: z.string().url(),
      }),
    },
  },
  handler: async ({ body, state }) => {
    const payment = await state.mollie.createPayment({
      amount: body.amount,
      description: body.description,
      redirectUrl: `${process.env.APP_URL}/checkout/return?order=${encodeURIComponent(body.orderId)}`,
      method: body.method,
      metadata: { orderId: body.orderId },
      idempotencyKey: `order:${body.orderId}:${randomUUID()}`,
    });
    return {
      status: 201,
      body: { paymentId: payment.id, checkoutUrl: payment.checkoutUrl },
    };
  },
});

6. Webhook

Mollie's webhook payload is famously minimalist: a form-encoded body of id=tr_xxx. You take that id, fetch the full payment from the API, and react to its current status. Always 200 OK quickly, even on not-interesting events — Mollie retries non-200 responses.

ts
import { readRawBody } from "@daloyjs/core/raw";

app.route({
  method: "POST",
  path: "/webhooks/mollie",
  operationId: "mollieWebhook",
  responses: {
    200: { description: "ok", body: z.object({ ok: z.literal(true) }) },
    401: { description: "bad signature", body: z.object({ error: z.string() }) },
  },
  handler: async ({ request, state }) => {
    const raw = await readRawBody(request);
    const signature = request.headers.get("x-mollie-signature");

    const status = await state.mollie.verifyWebhook(raw, signature);
    if (status === "invalid") {
      return { status: 401, body: { error: "bad signature" } };
    }
    // "legacy" = unsigned (old subscription). Decide if you want to accept these.
    // In production with a fresh subscription, require "valid".

    const params = new URLSearchParams(raw);
    const paymentId = params.get("id");
    if (!paymentId) return { status: 200, body: { ok: true as const } };

    const payment = await state.mollie.getPayment(paymentId);

    // payment.status ∈ "open" | "pending" | "authorized" | "paid" | "expired" | "failed" | "canceled"
    if (payment.status === "paid") {
      // Fulfil the order. Use the metadata.orderId set at creation.
    }

    return { status: 200, body: { ok: true as const } };
  },
});

7. Refunds, captures, and cancellation

ts
// Full refund
await state.mollie.raw.refunds.create({
  paymentId: "tr_xxx",
  refundRequest: {
    amount: { currency: "EUR", value: "10.00" },
    description: "Customer request",
  },
});

// Capture a previously authorized card payment (when capture mode = manual)
await state.mollie.raw.captures.create({
  paymentId: "tr_xxx",
  captureRequest: {
    amount: { currency: "EUR", value: "10.00" },
  },
});

// Cancel an open payment
await state.mollie.raw.payments.cancel({ paymentId: "tr_xxx" });

Pagination

List endpoints return async iterables — let for await walk the pages for you:

ts
const pages = await state.mollie.raw.payments.list({ limit: 50 });
for await (const page of pages) {
  for (const payment of page.embedded?.payments ?? []) {
    // ...
  }
}

Runtimes

The SDK is built on the Fetch API and ships ESM + CJS, so it runs on Node 18+, Cloudflare Workers, Vercel Edge, Bun, and Deno without adapters. The webhook verifier is pure crypto using crypto.subtle.importKey / sign under the hood and works in every modern runtime.

Errors

Mollie returns RFC-7807-shaped problem details. Catch errors.ErrorResponse and map it through problem+json:

ts
import * as errors from "mollie-api-typescript/models/errors";

try {
  await state.mollie.createPayment(/* ... */);
} catch (e) {
  if (e instanceof errors.ErrorResponse) {
    // e.data$.status, e.data$.title, e.data$.detail, e.data$.field
    throw new HttpError(e.data$.status, "https://mollie.com/errors", e.data$.title, e.data$.detail);
  }
  throw e;
}

Modernisation notes

  • Use the TypeScript SDK over the JS client. mollie-api-typescript ships first-class types, tree-shakable standalone functions, async-iterable pagination, and a Fetch-based HTTP client that runs at the edge. The older @mollie/api-client is fine for legacy code but no longer the recommended starting point.
  • Verify signatures.Mollie added signed webhooks specifically so you don't have to rely on "refetch the payment and hope IP allow-lists are right". Use SignatureValidator with the raw body.
  • Idempotency keys on every create. The SDK accepts idempotencyKeyalongside the request body — pass one derived from your order id so a network retry doesn't spawn duplicate payments.
  • Don't trust the redirect. The redirectUrl only tells you the user came back — they could close the tab mid-iDEAL. The webhook + a follow-up payments.get is what flips an order to paid.

See also the payments overview, Adyen guide, Braintree guide, and problem+json errors.