Skip to content

Search docs

Jump between documentation pages.

Browse docs

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

Redirect payment flow
ShopperDaloyJS routeMollie
  1. 01requestShopperDaloyJS routePOST /checkout/mollieorderId + amount { value: '10.00' }
  2. 02requestDaloyJS routeMolliepayments.create with an idempotencyKeyredirectUrl + webhookUrl
  3. 03responseDaloyJS routeShopperReturn _links.checkout.href201 { checkoutUrl }
  4. 04asyncShopperMolliePays on the hosted checkout, returns via redirectUrlredirect is a UX hint, not proof
Create the payment with an idempotency key, hand the shopper the hosted checkout URL, and treat the redirect back as a UX signal only. The webhook is the source of truth.
ts
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { molliePlugin } from "./plugins/mollie.ts";

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.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

Webhook verification
MollieDaloyJS route
  1. 01requestMollieDaloyJS routePOST /webhooks/mollieX-Mollie-Signature: sha256=... over id=tr_xxx
  2. 02noteDaloyJS routeDaloyJS routeSignatureValidator over the raw bodyvalid | legacy (unsigned) | invalid
  3. 03responseDaloyJS routeMollie401 on invalid, otherwise payments.get(id)refetch the authoritative status
  4. 04asyncDaloyJS routeMollieFulfil when status === 'paid', ack 200use metadata.orderId
Verify X-Mollie-Signature over the raw body, refetch the full payment with payments.get because the webhook body carries only the id, then fulfil on the 'paid' status and ack 200 fast.

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, 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.