Adyen is a single platform for cards, wallets, and local payment methods across Europe, the US, APAC, and LATAM. This guide uses the official @adyen/api-library Node SDK (Checkout API v71 as of v30.x) from a DaloyJS server, leans on the Sessions flow for the frontend, and verifies Standard webhook notifications with the bundled hmacValidator.
What you should know up front
Use Sessions, not /payments directly. The modern way to integrate Adyen Web Drop-in / Components is the Sessions flow , your server creates a session, the frontend hands it to Drop-in, and Adyen handles 3-D Secure 2, redirects, and payment-method-specific quirks for you. Direct /payments is still supported for server-to-server use cases.
Live needs a URL prefix. Production Checkout calls go through a merchant-specific endpoint. Set liveEndpointUrlPrefix on the Clientfor any API that requires it (Checkout, BinLookup, BalanceControl, Payout, Recurring). Forgetting this is the #1 cause of “works in test, 404 in live”.
Webhooks come signed. Each NotificationRequestItemcarries an HMAC-SHA256 of selected fields in additionalData.hmacSignature. Verify with hmacValidator.validateHMAC and respond [accepted] within ~10 seconds, or Adyen marks it failed and retries.
Node 18+. Older runtimes are unsupported.
Amounts are minor units. EUR 10.00 → { currency: "EUR", value: 1000 }. JPY 1000 → value: 1000. Get this wrong and you'll overcharge by 100×.
1. Provision
Create a test account and a merchant account inside it.
Generate an API key (Customer Area → Developers → API credentials) and grant it the Checkout webservice role.
Configure a Standard notification webhook in Customer Area → Developers → Webhooks. Point it at your DaloyJS endpoint, choose JSON, generate an HMAC key, and enable Basic Auth.
For production: note your liveEndpointUrlPrefix (Customer Area → Developers → API URLs, looks like 1797a841fbb37ca7-AdyenDemo).
2. Install
ts
pnpm add @adyen/api-library
3. Environment variables
ts
# .envADYEN_ENVIRONMENT=TEST # or LIVEADYEN_API_KEY=AQE...replace_meADYEN_MERCHANT_ACCOUNT=YourMerchantAccountNameADYEN_HMAC_KEY=hex_string_from_customer_area # webhook signing keyADYEN_WEBHOOK_USER=adyen # Basic auth usernameADYEN_WEBHOOK_PASSWORD=replace_me # Basic auth passwordADYEN_LIVE_URL_PREFIX= # required when ENVIRONMENT=LIVEADYEN_CLIENT_KEY=test_...replace_me # public key, ship to the browser
02noteDaloyJS routeDaloyJS routeCheck Basic auth, then validateHMAC per itemadditionalData.hmacSignature, HMAC-SHA256
03responseDaloyJS routeAdyen401 on bad Basic auth or HMAC{ error: 'bad hmac' }
04asyncDaloyJS routeYour queueDedupe on pspReference, enqueue, ack [accepted]200 { notificationResponse: '[accepted]' } within ~10s
Check Basic auth, validate the per-item HMAC with hmacValidator, dedupe on pspReference + eventCode, then always answer 200 [accepted] and process AUTHORISATION asynchronously.
Adyen posts JSON like { "live": "false", "notificationItems": [{ "NotificationRequestItem": { ... } }] }. Verify HMAC, ack before processing, then enqueue:
ts
import { z } from "zod";import { timingSafeEqual } from "node:crypto";import type { Types } from "@adyen/api-library";function basicAuthOk(headerValue: string | null): boolean { if (!headerValue?.startsWith("Basic ")) return false; const expected = "Basic " + Buffer.from(`${process.env.ADYEN_WEBHOOK_USER}:${process.env.ADYEN_WEBHOOK_PASSWORD}`).toString("base64"); const a = Buffer.from(headerValue); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);}app.route({ method: "POST", path: "/webhooks/adyen", operationId: "adyenWebhook", request: { body: z.object({ live: z.string(), notificationItems: z.array( z.object({ NotificationRequestItem: z.any() }), ), }), }, responses: { 200: { description: "ack", body: z.object({ notificationResponse: z.literal("[accepted]") }) }, 401: { description: "unauthorized", body: z.object({ error: z.string() }) }, }, handler: async ({ body, request, state }) => { if (!basicAuthOk(request.headers.get("authorization"))) { return { status: 401, body: { error: "bad basic auth" } }; } for (const wrapper of body.notificationItems) { const item = wrapper.NotificationRequestItem as Types.notification.NotificationRequestItem; if (!state.adyen.verifyWebhookItem(item)) { return { status: 401, body: { error: "bad hmac" } }; } // pspReference + eventCode + success makes a stable dedupe key. const dedupe = `${item.pspReference}:${item.eventCode}:${item.success}`; if (await seen(dedupe)) continue; await enqueueAdyenEvent(item); } // Always 200 + [accepted] when HMAC + auth check pass; do the work async. return { status: 200, body: { notificationResponse: "[accepted]" as const } }; },});
The event you care about most is AUTHORISATION with success === "true": that's the canonical "the money is good" signal. The HTTP response from /payments or the Sessions success callback is only a hint; webhooks are the source of truth.
The SDK uses Node's built-in https module out of the box. It runs on Node 18+ and works on classic Node serverless. For edge runtimes (Cloudflare Workers) you either swap in a fetch-based HttpClient via new Client({ httpClient: { request(endpoint, json, config) { ... } } }) or POST directly to https://checkout-test.adyen.com/v71/sessions with fetch. The HMAC verification helper is pure JS and works anywhere.
Errors
Adyen returns RFC-7807-shaped errors with status, errorCode, message, and errorType. The SDK throws HttpClientException with those fields on the .error object; map them through problem+json like other providers.
Modernisation notes
Sessions over /payments + /payments/details. The two-step Advanced flow still works, but Sessions is now the default in Adyen's own examples and removes a class of state-management bugs.
Use Web v5+ on the client. v5 expects a session response shape identical to what PaymentsApi.sessions returns; older Drop-in versions required wiring up onSubmit / onAdditionalDetails callbacks by hand.
Don't roll your own HMAC. Adyen signs a specific colon-delimited subset of fields with a quirky escape rule. Let hmacValidator handle it.
Network tokens by default. When you tokenise with storePaymentMethod: true and reuse via shopperInteraction: "ContAuth", Adyen will route through scheme tokens automatically, no extra code, lower decline rate.