Search docs

Jump between documentation pages.

Accept payments with Square in DaloyJS

Square gives you one API across in-person, online, and recurring payments — useful when the same merchant takes both Tap to Pay at the counter and Apple Pay through your web app. This guide uses the modern square TypeScript SDK (v40+, currently v44.x — a full rewrite from the pre-v40 line), Square's Web Payments SDK on the client for tokenisation, and WebhooksHelper.verifySignature on the server for webhook auth.

What you should know up front

  • v40+ is a full rewrite. If you see squareConnect, new Client({ bearerAuthCredentials }), or paymentsApi.createPaymentin tutorials, you're looking at the legacy SDK. The new client is SquareClient, calls are client.payments.create(...), and parameters are camelCase. The legacy surface is still shipped as square/legacyfor migration only — don't start there in 2026.
  • Money is BigInt, in the smallest unit. { amount: BigInt("1000"), currency: "USD" } means $10.00. Pass a plain number and TypeScript will (correctly) yell at you;JSON.stringifying a BigInt without a replacer will throw at runtime. See the serialisation note below.
  • You don't charge a card — you charge a source ID. The Web Payments SDK on the client returns a single-use token (cnon:... for cards, cash:, Apple Pay nonces, etc.) that your server passes as sourceId. Raw PANs never touch your code.
  • Always send an idempotencyKey. Required on every mutating call (payments.create, refunds.refundPayment, orders, etc.). A UUID per logical attempt is the right shape — re-use it on retries.
  • Webhook verification needs three things, not two. WebhooksHelper.verifySignature wants the raw body, the signature header, the signature key, and the exact notificationUrl you registered in the dashboard. Get the URL even slightly wrong (trailing slash, http vs https, behind a proxy that strips the host) and every event will look invalid.

1. Provision

  1. Sign in at developer.squareup.com and create an application.
  2. Grab a Sandbox access token and your Application ID from Credentials. Also note a Location ID from Locations — every payment needs one.
  3. Under Webhooks → Subscriptions, add an endpoint pointing at your DaloyJS route, subscribe to at least payment.updated, payment.created, and refund.updated, and copy the Signature Key. Save the full URL exactly as Square shows it.

2. Install

ts
pnpm add square

3. Environment variables

ts
# .env
SQUARE_ACCESS_TOKEN=EAAA_replace_me                  # sandbox or production access token
SQUARE_APPLICATION_ID=sandbox-sq0idb-replace_me      # public; also used by Web Payments SDK
SQUARE_LOCATION_ID=L_replace_me                      # default location for charges
SQUARE_WEBHOOK_SIGNATURE_KEY=replace_me              # per-subscription, from the dashboard
SQUARE_WEBHOOK_URL=https://your-app.example.com/webhooks/square
SQUARE_ENV=sandbox                                   # "sandbox" | "production"

4. Plugin

ts
// src/plugins/square.ts
import { randomUUID } from "node:crypto";
import { SquareClient, SquareEnvironment, SquareError, WebhooksHelper } from "square";
import type { App } from "@daloyjs/core";

const square = new SquareClient({
  token: process.env.SQUARE_ACCESS_TOKEN!,
  environment:
    process.env.SQUARE_ENV === "production"
      ? SquareEnvironment.Production
      : SquareEnvironment.Sandbox,
});

export interface SquareClientWrapper {
  raw: SquareClient;

  createPayment(input: {
    sourceId: string;                  // token from Web Payments SDK
    amountMinor: bigint;               // smallest unit — cents for USD, etc.
    currency: string;                  // ISO-4217, uppercase
    idempotencyKey?: string;           // defaults to randomUUID()
    locationId?: string;               // defaults to env LOCATION_ID
    referenceId?: string;              // your internal id (≤ 40 chars)
    note?: string;                     // ≤ 500 chars
    autocomplete?: boolean;            // default true (capture on auth)
  }): Promise<{ id: string; status: string; orderId?: string; receiptUrl?: string }>;

  verifyWebhookSignature(rawBody: string, signatureHeader: string | null): boolean;

  getPayment(paymentId: string): Promise<{ id: string; status: string; amountMinor: bigint; currency: string }>;

  refund(input: {
    paymentId: string;
    amountMinor: bigint;
    currency: string;
    idempotencyKey?: string;
    reason?: string;
  }): Promise<{ id: string; status: string }>;
}

export const squarePlugin = {
  name: "square",
  register(app: App) {
    const client: SquareClientWrapper = {
      raw: square,

      async createPayment({
        sourceId,
        amountMinor,
        currency,
        idempotencyKey = randomUUID(),
        locationId = process.env.SQUARE_LOCATION_ID!,
        referenceId,
        note,
        autocomplete = true,
      }) {
        const { payment } = await square.payments.create({
          sourceId,
          idempotencyKey,
          amountMoney: { amount: amountMinor, currency },
          locationId,
          referenceId,
          note,
          autocomplete,
        });
        if (!payment) throw new Error("Square returned no payment");
        return {
          id: payment.id!,
          status: payment.status!,
          orderId: payment.orderId,
          receiptUrl: payment.receiptUrl,
        };
      },

      verifyWebhookSignature(rawBody, signatureHeader) {
        if (!signatureHeader) return false;
        return WebhooksHelper.verifySignature({
          requestBody: rawBody,
          signatureHeader,
          signatureKey: process.env.SQUARE_WEBHOOK_SIGNATURE_KEY!,
          notificationUrl: process.env.SQUARE_WEBHOOK_URL!,
        });
      },

      async getPayment(paymentId) {
        const { payment } = await square.payments.get({ paymentId });
        if (!payment) throw new Error(`Square payment ${paymentId} not found`);
        return {
          id: payment.id!,
          status: payment.status!,
          amountMinor: payment.amountMoney!.amount!,
          currency: payment.amountMoney!.currency!,
        };
      },

      async refund({ paymentId, amountMinor, currency, idempotencyKey = randomUUID(), reason }) {
        const { refund } = await square.refunds.refundPayment({
          paymentId,
          idempotencyKey,
          amountMoney: { amount: amountMinor, currency },
          reason,
        });
        if (!refund) throw new Error("Square returned no refund");
        return { id: refund.id!, status: refund.status! };
      },
    };

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

export { SquareError };

declare module "@daloyjs/core" {
  interface AppState {
    square: SquareClientWrapper;
  }
}

5. Create a payment

The client uses Square's Web Payments SDK with your SQUARE_APPLICATION_ID + SQUARE_LOCATION_ID, tokenises the card, and posts the sourceId to this endpoint.

ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { squarePlugin, SquareError } from "./plugins/square";

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

app.route({
  method: "POST",
  path: "/checkout/square",
  operationId: "createSquarePayment",
  request: {
    body: z.object({
      sourceId: z.string().min(1),                  // from Web Payments SDK
      amountMinor: z.coerce.bigint().positive(),    // accepts string from JSON
      currency: z.string().length(3),
      orderId: z.string().min(1).max(40),           // your internal id
      note: z.string().max(500).optional(),
    }),
  },
  responses: {
    201: {
      description: "captured",
      body: z.object({
        paymentId: z.string(),
        status: z.string(),
        receiptUrl: z.string().url().optional(),
      }),
    },
    402: { description: "card declined", body: z.object({ error: z.string() }) },
  },
  handler: async ({ body, state }) => {
    try {
      const payment = await state.square.createPayment({
        sourceId: body.sourceId,
        amountMinor: body.amountMinor,
        currency: body.currency,
        referenceId: body.orderId,
        note: body.note,
      });
      return {
        status: 201,
        body: { paymentId: payment.id, status: payment.status, receiptUrl: payment.receiptUrl },
      };
    } catch (err) {
      if (err instanceof SquareError && err.statusCode === 402) {
        const detail = err.errors?.[0]?.detail ?? "card declined";
        return { status: 402, body: { error: detail } };
      }
      throw err;
    }
  },
});

BigInt + JSON gotcha:JavaScript's default JSON serialiser throws on BigInt. Map money to strings at the response edge (amountMinor.toString()) or use a custom replacer. DaloyJS's Zod responses already coerce BigInt to string when you declare the response as z.string(); declare a z.bigint() only when both ends agree on it.

6. Webhook

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

app.route({
  method: "POST",
  path: "/webhooks/square",
  operationId: "squareWebhook",
  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-square-hmacsha256-signature");

    if (!state.square.verifyWebhookSignature(raw, signature)) {
      return { status: 401, body: { error: "bad signature" } };
    }

    const event = JSON.parse(raw) as {
      type: string;
      event_id: string;
      data: { type: string; id: string; object?: unknown };
    };

    // Idempotency: keep a 24h record of event.event_id; Square retries non-2xx.
    switch (event.type) {
      case "payment.updated":
      case "payment.created": {
        // Re-fetch with the SDK to get the authoritative status before fulfilling.
        const fresh = await state.square.getPayment(event.data.id);
        if (fresh.status === "COMPLETED") {
          // Mark order paid — idempotent on (payment.id).
        }
        break;
      }
      case "refund.updated":
        // Reconcile refund state.
        break;
    }

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

Square retries non-2xx responses with backoff for up to 72 hours. Once the signature checks out, ack with 200 even for event types you don't handle, and do the slow work asynchronously.

7. Refunds

ts
// Full refund — use the original payment's amount.
await state.square.refund({
  paymentId: "pay_xxx",
  amountMinor: BigInt(1000),     // $10.00
  currency: "USD",
  reason: "Customer changed mind",
});

// Partial refund — amount strictly less than the captured amount.
await state.square.refund({
  paymentId: "pay_xxx",
  amountMinor: BigInt(250),      // $2.50 back
  currency: "USD",
});

Runtimes

The v40+ SDK is Fern-generated and uses the platform fetch when available, falling back to node-fetch. Square officially supports Node.js 18+, Vercel (Edge and Node), Cloudflare Workers, Deno 1.25+, Bun 1.0+, and React Native — so the same plugin runs on Edge runtimes unchanged. The only thing to watch is reading the raw body: on Edge, use await request.text() instead of readRawBodyif your adapter doesn't expose Node streams.

Errors

Non-2xx responses throw SquareError. Inspect err.statusCode, err.body, and the structured err.errors[] array — each entry has category (e.g. PAYMENT_METHOD_ERROR), code (e.g. CARD_DECLINED, CVV_FAILURE), detail, and an optional field. Map them through problem+json with type: square:<category>:<code> so reconciliation tools can join them later.

Modernisation notes

  • Use the new SDK, not square/legacy.The legacy export exists so v39 codebases can migrate piecemeal — there's no reason to start a new integration on it in 2026. New features ship to the new client first (or only).
  • Pin a Square API version in production. The SDK is tied to a Square API version per release, and a new SDK major (v40 → v41 → ...) can be a breaking change. Pin square to a caret range you control and read the changelog before bumping.
  • Iterate paginated endpoints with for await. List responses are async-iterable: for (const item of pageable) (synchronously) only gives you the first page; use for await (const item of pageable) to walk all pages without manual cursor juggling.
  • Verify webhooks; don't trust the source IP.Square's IP ranges change. The HMAC + the registered notification URL together prove authenticity and that the request hit the right endpoint.

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