# Hosted Checkout (/vi/online/integration/hosted_checkout)





Chuyển khách hàng đến một trang thanh toán do Tapaya lưu trữ cho thẻ, Apple Pay và Google Pay, sau đó xác minh kết quả trên
máy chủ của bạn. Trang của bạn không bao giờ xử lý thông tin thẻ, và bạn không cần thêm trường nhập thẻ nào vào cửa hàng.

<CheckoutFlowDiagram />

## Trước khi bắt đầu [#before-you-start]

Chọn key phù hợp với bên vận hành backend của bạn. Dù dùng key nào, hãy giữ nó trên máy chủ; [Bảo mật
Checkout](/online/security/checkout_security) liệt kê các quy tắc phía máy chủ.

<Cards>
  <VisualCard title="Một cửa hàng" visual="<Store />" href="/online/integration/checkout_settings#create-merchant-checkout-keys" description="Dùng checkout key của merchant. Key này tự xác định merchant của nó, và các ví dụ ở đây dùng key này." />

  <VisualCard title="Nhiều merchant" visual="<Building2 />" href="/online/integration/api_integration#authentication" description="Dùng Secret key của tổ chức và gửi merchant token trong mọi yêu cầu." />
</Cards>

Sau đó, trong **Checkout** của [Tapaya Platform](https://platform.tapaya.com/checkout-settings):

1. **Thêm merchant của bạn** và kiểm tra rằng merchant hiển thị **Ready**. Xem [Cài đặt Checkout](/online/integration/checkout_settings).
2. **Đăng ký cửa hàng của bạn** làm website được phép, kèm URL thành công và URL hủy mặc định, cho key bạn đã chọn. Các
   ví dụ dùng `https://shop.example`.

<Callout title="URL trả về đi theo key" type="info">
  Các phiên tạo bằng key của merchant dùng URL trả về của merchant. Các phiên tạo bằng Secret key của tổ chức thì dùng
  [đích trả về](/online/integration/checkout_settings#configure-return-destinations) của tổ chức.
  Hai cấu hình này không bao giờ được kết hợp.
</Callout>

## Nhận một khoản thanh toán [#accept-a-payment]

Cả ba lựa chọn đều tạo cùng loại phiên Checkout với cùng thông tin xác thực. Hãy chọn một; mọi bước bên dưới đều theo
lựa chọn của bạn.

<div className="fd-steps [&_h3]:fd-step">
  ### Thiết lập máy chủ [#set-up-your-server]

  <Tabs defaultValue="fetch" groupId="checkout-server" className="border-0 bg-transparent">
    <TabsList>
      <TabsTrigger value="fetch">
        HTTP (fetch)
      </TabsTrigger>

      <TabsTrigger value="node">
        Node.js SDK
      </TabsTrigger>

      <TabsTrigger value="curl">
        cURL
      </TabsTrigger>
    </TabsList>

    <TabsContent value="fetch">
      Đặt `TAPAYA_CHECKOUT_API_URL` thành `https://api.sandbox.tapaya.com` và lưu key của bạn vào `TAPAYA_CHECKOUT_API_KEY`.
      Gọi API bằng `fetch` của môi trường chạy trên máy chủ, không bao giờ gọi từ trình duyệt của khách hàng. Các ví dụ đọc những giá trị này
      qua `process.env`; hãy điều chỉnh cách đọc nếu môi trường chạy của bạn dùng một giao diện quản lý secret khác.

      Tạo một hàm hỗ trợ kiểm tra lỗi HTTP trước khi trả về phần thân phản hồi:

      ```js title="server/checkout.js"
      const apiUrl = process.env.TAPAYA_CHECKOUT_API_URL;
      const apiKey = process.env.TAPAYA_CHECKOUT_API_KEY;

      async function checkoutRequest(path, options = {}) {
      	const response = await fetch(new URL(path, apiUrl), {
      		...options,
      		headers: {
      			...options.headers,
      			Authorization: `Bearer ${apiKey}`,
      		},
      		redirect: 'error',
      		cache: 'no-store',
      		signal: AbortSignal.timeout(8000),
      	});

      	if (!response.ok) {
      		throw new Error(`Checkout request failed (${response.status})`);
      	}

      	return response.json();
      }
      ```
    </TabsContent>

    <TabsContent value="node">
      Dùng Node.js 22.13.0 trở lên và cài đặt gói ESM trên máy chủ của bạn:

      <CodeBlockTabs defaultValue="npm" groupId="package-manager">
        <CodeBlockTabsList>
          <CodeBlockTabsTrigger value="npm">
            npm
          </CodeBlockTabsTrigger>

          <CodeBlockTabsTrigger value="pnpm">
            pnpm
          </CodeBlockTabsTrigger>

          <CodeBlockTabsTrigger value="bun">
            Bun
          </CodeBlockTabsTrigger>
        </CodeBlockTabsList>

        <CodeBlockTab value="npm">
          ```sh
          npm install @tapayadot/checkout
          ```
        </CodeBlockTab>

        <CodeBlockTab value="pnpm">
          ```sh
          pnpm add @tapayadot/checkout
          ```
        </CodeBlockTab>

        <CodeBlockTab value="bun">
          ```sh
          bun add @tapayadot/checkout
          ```
        </CodeBlockTab>
      </CodeBlockTabs>

      Lưu key của bạn vào `TAPAYA_SECRET_KEY` và chọn môi trường sandbox. SDK không tự tải tệp `.env`,
      vì vậy hãy nạp các giá trị này vào môi trường của máy chủ trước khi khởi tạo client.

      ```sh title=".env"
      TAPAYA_SECRET_KEY=tapaya_test_...
      TAPAYA_ENVIRONMENT=sandbox
      ```

      ```ts title="server/checkout.ts"
      import { Tapaya } from '@tapayadot/checkout';

      const tapaya = new Tapaya(); // reads TAPAYA_SECRET_KEY
      ```

      Khi không có `TAPAYA_ENVIRONMENT`, key `tapaya_live_` sẽ chọn production, còn các key khác mặc định dùng sandbox. Ở
      chế độ nền tảng, hãy truyền merchant token: `new Tapaya(key, { merchantToken })`, hoặc `merchantToken` trong mỗi lệnh gọi. Nếu
      key của bạn được lưu dưới tên khác, hãy truyền nó làm đối số đầu tiên. [Tài liệu tham khảo Node.js
      SDK](/online/integration/node_sdk) trình bày URL API tùy chỉnh, thời gian chờ, thử lại và lỗi.
    </TabsContent>

    <TabsContent value="curl">
      Chạy các lệnh này từ máy chủ của bạn hoặc một terminal đáng tin cậy. Nạp key của bạn vào `TAPAYA_CHECKOUT_API_KEY`, giữ key
      ngoài mã trình duyệt và hệ thống quản lý mã nguồn, và trỏ URL API đến sandbox:

      ```bash
      export TAPAYA_CHECKOUT_API_URL='https://api.sandbox.tapaya.com'
      ```
    </TabsContent>
  </Tabs>

  ### Tạo một phiên [#create-a-session]

  Tính tổng tiền đơn hàng trên máy chủ của bạn, bao gồm thuế và phí giao hàng. Lưu đơn hàng và một
  `checkoutAttemptKey` duy nhất **trước khi** gửi yêu cầu, và tải `order` từ bản lưu (snapshot) đó. `merchantOrderId`
  là bắt buộc.

  <Tabs defaultValue="fetch" groupId="checkout-server" className="border-0 bg-transparent">
    <TabsList>
      <TabsTrigger value="fetch">
        HTTP (fetch)
      </TabsTrigger>

      <TabsTrigger value="node">
        Node.js SDK
      </TabsTrigger>

      <TabsTrigger value="curl">
        cURL
      </TabsTrigger>
    </TabsList>

    <TabsContent value="fetch">
      ```js
      const session = await checkoutRequest('/merchant/checkout-sessions', {
      	method: 'POST',
      	headers: {
      		'Content-Type': 'application/json',
      		'Idempotency-Key': order.checkoutAttemptKey,
      	},
      	body: JSON.stringify({
      		merchantOrderId: order.id,
      		amount: order.totalInMinorUnits,
      		currency: order.currency,
      		successUrl: 'https://shop.example/checkout/return',
      		cancelUrl: 'https://shop.example/checkout/cancel',
      	}),
      });
      ```
    </TabsContent>

    <TabsContent value="node">
      ```ts
      const session = await tapaya.checkout.sessions.create(
      	{
      		merchantOrderId: order.id,
      		amount: order.totalInMinorUnits,
      		currency: order.currency,
      		successUrl: 'https://shop.example/checkout/return',
      		cancelUrl: 'https://shop.example/checkout/cancel',
      	},
      	{ idempotencyKey: order.checkoutAttemptKey },
      );
      ```
    </TabsContent>

    <TabsContent value="curl">
      Ví dụ này trừ 121,00 EUR cho `order-1001`, bao gồm 21,00 EUR thuế VAT và 6,05 EUR phí giao hàng, với
      idempotency key đã lưu là `order-1001-checkout-1`. Hãy thay các giá trị này bằng thông tin đơn hàng bạn đã lưu.

      ```bash
      curl --request POST \
      	"$TAPAYA_CHECKOUT_API_URL/merchant/checkout-sessions" \
      	--fail-with-body --silent --show-error --max-time 8 \
      	--header "Authorization: Bearer $TAPAYA_CHECKOUT_API_KEY" \
      	--header 'Content-Type: application/json' \
      	--header 'Idempotency-Key: order-1001-checkout-1' \
      	--data '{
      		"merchantOrderId": "order-1001",
      		"amount": 12100,
      		"currency": "EUR",
      		"tax": 2100,
      		"shipping": 605,
      		"taxRate": 21,
      		"successUrl": "https://shop.example/checkout/return",
      		"cancelUrl": "https://shop.example/checkout/cancel"
      	}'
      ```
    </TabsContent>
  </Tabs>

  Phản hồi chứa `id` của phiên và `url` của trang Hosted Checkout. Để biết về tóm tắt mặt hàng, chi tiết thuế, thông tin khách hàng
  và mọi trường phản hồi, xem [Tạo phiên Checkout](/online/integration/api_integration#create-a-checkout-session).

  **Nếu yêu cầu thất bại hoặc hết thời gian chờ,** hãy thử lại với cùng key và phần thân không thay đổi. Không bao giờ tạo key mới khi
  kết quả chưa chắc chắn: một phần thân đã thay đổi đi kèm cùng key sẽ trả về HTTP 409.

  | Lựa chọn           | Tự động thử lại                                                                                                                                                                                                                                                                                                                     |
  | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | HTTP (fetch), cURL | Không. Bạn tự lặp lại yêu cầu. Xem [Idempotency và lỗi](/online/integration/api_integration#idempotency-and-errors).                                                                                                                                                                                                                |
  | Node.js SDK        | Tối đa hai lần sau lỗi kết nối, hết thời gian chờ, hoặc HTTP 429, 502, 503 hay 504, với cùng key và phần thân. Xem [Idempotency và thử lại](/online/integration/node_sdk#idempotency-and-retries). Giữa các lệnh gọi riêng biệt hoặc sau khi khởi động lại, hãy truyền key bạn đã lưu: nếu bỏ qua, mỗi lệnh gọi sẽ tạo một key mới. |

  ### Chuyển hướng khách hàng [#redirect-the-customer]

  Lưu `session.id` cùng với đơn hàng làm `checkoutSessionId` của đơn hàng, sau đó chuyển trình duyệt đến `session.url` bằng HTTP
  303\. Dùng nguyên URL được trả về thay vì tự tạo URL.

  ```ts
  // First persist session.id as the order's checkoutSessionId.
  return Response.redirect(session.url, 303);
  ```

  Nếu framework của bạn dùng một API phản hồi khác, hãy gửi HTTP 303 với `session.url` trong header `Location`. Với
  cURL, hãy mở `url` được trả về trong trình duyệt để kiểm thử thanh toán thủ công.

  ### Xác minh thanh toán [#verify-the-payment]

  Trong trình xử lý trang trả về, hãy xác định đơn hàng từ ngữ cảnh checkout của người dùng đã đăng nhập trong ứng dụng và truy xuất phiên
  đã lưu của đơn hàng đó từ máy chủ. Hãy làm như vậy cả trên URL hủy: việc truy cập bất kỳ URL nào trong hai URL này đều không chứng minh điều gì.

  <Tabs defaultValue="fetch" groupId="checkout-server" className="border-0 bg-transparent">
    <TabsList>
      <TabsTrigger value="fetch">
        HTTP (fetch)
      </TabsTrigger>

      <TabsTrigger value="node">
        Node.js SDK
      </TabsTrigger>

      <TabsTrigger value="curl">
        cURL
      </TabsTrigger>
    </TabsList>

    <TabsContent value="fetch">
      ```js
      const current = await checkoutRequest(
      	`/merchant/checkout-sessions/${encodeURIComponent(order.checkoutSessionId)}`,
      );

      if (
      	current.id !== order.checkoutSessionId ||
      	current.merchantOrderId !== order.id ||
      	current.amount !== order.totalInMinorUnits ||
      	current.currency !== order.currency
      ) {
      	throw new Error('Checkout session does not match the order');
      }

      const paid = current.paymentStatus === 'successful';
      ```
    </TabsContent>

    <TabsContent value="node">
      ```ts
      const current = await tapaya.checkout.sessions.get(order.checkoutSessionId);

      if (
      	current.id !== order.checkoutSessionId ||
      	current.merchantOrderId !== order.id ||
      	current.amount !== order.totalInMinorUnits ||
      	current.currency !== order.currency
      ) {
      	throw new Error('Checkout session does not match the order');
      }

      const paid = current.paymentStatus === 'successful';
      ```
    </TabsContent>

    <TabsContent value="curl">
      Nạp ID phiên đã lưu cùng đơn hàng vào `CHECKOUT_SESSION_ID`:

      ```bash
      curl "$TAPAYA_CHECKOUT_API_URL/merchant/checkout-sessions/$CHECKOUT_SESSION_ID" \
      	--fail-with-body --silent --show-error --max-time 8 \
      	--header "Authorization: Bearer $TAPAYA_CHECKOUT_API_KEY"
      ```

      Với đơn hàng ví dụ, hãy kiểm tra rằng `id` khớp với ID phiên đã lưu, `merchantOrderId` là `order-1001`, `amount`
      là `12100`, `currency` là `EUR` và `paymentStatus` là `successful`. Chỉ riêng HTTP 200 không chứng minh việc thanh toán.
    </TabsContent>
  </Tabs>

  <Callout title="Hoàn tất đơn hàng một lần, và chỉ khi đã thanh toán" type="warn">
    Chỉ ghi nhận khoản thanh toán và hoàn tất đơn hàng khi phiên khớp với đơn hàng đã lưu và `paymentStatus` là
    `successful`, đồng thời ghi nhận một cách nguyên tử (atomic) để việc này chỉ xảy ra một lần. Nếu truy xuất thất bại hoặc khoản thanh toán chưa xác định kết quả, hãy giữ
    ID phiên và kiểm tra lại. Xem [Trạng thái thanh toán](/online/integration/api_integration#payment-status).
  </Callout>
</div>

## Xử lý kết quả thanh toán [#handle-payment-outcomes]

[Bảo mật Checkout](/online/security/checkout_security#verify-before-you-fulfill) trình bày những gì cần xác minh và cách
thử lại khi kết quả chưa rõ mà không tạo phiên thứ hai.

| Kết quả                         | Phiên hiển thị gì                                    | Cần làm gì                                                                                                                                                                                                        |
| ------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Bị từ chối**                  | `open`, với `paymentStatus: failed`                  | Không cần làm gì. Khách hàng có thể thử thẻ hoặc ví khác trên cùng trang.                                                                                                                                         |
| **Chưa xác định**               | `open` và `unpaid` trong khi Tapaya xác nhận kết quả | Truy xuất lại phiên sau.                                                                                                                                                                                          |
| **Phiên mới cho cùng đơn hàng** | Phiên chưa thanh toán trước đó báo `expired`         | Dùng phiên mới. Một đơn hàng đã thanh toán, hoặc đang có khoản thanh toán được xử lý, sẽ trả về HTTP 409. Xem [Một khoản thanh toán cho mỗi đơn hàng](/online/integration/api_integration#one-payment-per-order). |
| **Hết hạn**                     | Ngừng nhận thanh toán mới sau 30 phút kể từ khi tạo  | Tạo phiên mới nếu đơn hàng vẫn chưa được thanh toán. Một khoản thanh toán đã gửi trước khi hết hạn vẫn được hoàn tất.                                                                                             |

<Callout title="Chưa có webhook" type="info">
  Hiện chưa có webhook cho Checkout. Hãy chạy một tác vụ đối soát phía máy chủ để truy xuất phiên cho những đơn hàng mà
  khách hàng không bao giờ quay lại.
</Callout>

## Kiểm tra tích hợp [#check-the-integration]

<Cards>
  <Card title="Thẻ thử nghiệm" href="/online/getting-started/testing" description="Chạy mọi kết quả thanh toán trên sandbox." />

  <Card title="Danh sách kiểm tra QA" href="/online/getting-started/checklist" description="Kiểm tra việc thử lại, checkout bị gián đoạn và hết hạn trước khi vận hành chính thức." />
</Cards>
