# Accept payments with Square in DaloyJS

[Square](https://squareup.com/) 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`](https://github.com/square/square-nodejs-sdk) 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.createPayment` in 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/legacy` for 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.stringify`ing 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](https://developer.squareup.com/apps) 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

**Diagram: Token handoff and charge**

Participants: Browser, DaloyJS route, Square

1. **Browser -> Square** (async) - Web Payments SDK tokenises the card - returns a single-use sourceId (cnon:...)
2. **Browser -> DaloyJS route** (request) - POST /checkout/square - sourceId + amountMinor (BigInt) + currency
3. **DaloyJS route -> Square** (request) - payments.create with an idempotencyKey - amountMoney { amount: bigint }, locationId
4. **DaloyJS route -> Browser** (response) - 201 captured, or 402 on SquareError decline - { paymentId, status, receiptUrl }

The browser tokenises the card into a single-use sourceId so raw PANs never reach your server. Send an idempotencyKey on payments.create and map a 402 SquareError to a card-declined response.

The client uses Square's [Web Payments SDK](https://developer.squareup.com/docs/web-payments/overview) 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.ts";

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

app.post(
  "/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.url().optional(),
        }),
      },
      402: { description: "card declined", body: z.object({ error: z.string() }) },
    },
  },
  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

**Diagram: Webhook verification**

Participants: Square, DaloyJS route

1. **Square -> DaloyJS route** (request) - POST /webhooks/square - x-square-hmacsha256-signature over raw body
2. **DaloyJS route -> DaloyJS route** (note) - WebhooksHelper.verifySignature - raw body + signatureKey + exact notificationUrl
3. **DaloyJS route -> Square** (response) - 401 when the signature does not match - { error: 'bad signature' }
4. **DaloyJS route -> Square** (async) - getPayment(id) to confirm COMPLETED, then ack - 200, dedupe on event_id

verifySignature needs the raw body, the signature key, and the exact registered notificationUrl. A wrong URL (trailing slash, http vs https) fails every event. Refetch the payment to confirm COMPLETED before fulfilling.

```ts
import { readBodyLimited } from "@daloyjs/core";

app.post(
  "/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() }) },
    },
  },
  async ({ request, state }) => {
    // Decode only after a bounded read. Keep this string unchanged for verification.
    const rawBytes = await readBodyLimited(request, 1_048_576);
    const raw = new TextDecoder().decode(rawBytes);
    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+, Cloudflare Workers, Deno 1.25+, Bun 1.0+, and React Native, so the same plugin runs on Edge runtimes unchanged. The portable `readBodyLimited()` helper reads the standard `Request` body while enforcing the explicit byte cap shown above.

## 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](/docs/errors) 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](/docs/payments), [Braintree guide](/docs/payments/braintree), [Authorize.Net guide](/docs/payments/authorize-net), and [problem+json errors](/docs/errors).

---

Source: https://daloyjs.dev/docs/payments/square