> ## Documentation Index
> Fetch the complete documentation index at: https://docs.atum.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Build with the SDK

> Submit a multi-rail sandbox payment with the Payment Gateway Client SDK. Includes raw HTTP submit/status examples; building the unsigned payment is done with the SDK or CLI.

## Overview

Send a multi-rail payment in the **sandbox** from your own code.

<Steps>
  <Step title="Approve USDC spend">One-time setup for your wallet on Arbitrum Sepolia — skip if you already did the CLI quickstart.</Step>
  <Step title="Build and sign">Your app calls the SDK to prepare the payment and signs it locally with your wallet.</Step>
  <Step title="Submit to the gateway">SDK posts the signed payment. Gateway returns a `payment_id`.</Step>
  <Step title="Gateway routes your payment to a settlement provider">Settlement providers compete to fulfill your payment — no action needed from you.</Step>
  <Step title="USDC moves across both rails">The script polls until settlement completes and prints the confirmation with both transaction hashes.</Step>
</Steps>

<Note>
  New to tokenized currency payments? Read [Atum infrastructure](/get-started/architecture/payment-rails-primer) first — about five minutes.
</Note>

You do not need to run any Atum backend services — only the hosted testnet gateway and your wallet.

| Surface                            | Works on public testnet? | Best for                                                                                                                                               |
| ---------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **CLI** (`send-payment`)           | ✅ Yes                    | First test, no code — [Try it (CLI)](/get-started/start-building/using-the-cli)                                                                        |
| **SDK** (`payment-gateway-client`) | ✅ Yes                    | TypeScript / Node apps (**this guide**)                                                                                                                |
| **HTTP** (`curl`)                  | ⚠️ Submit/status only    | There is no HTTP endpoint to build the unsigned payment — assemble and sign it with the SDK or CLI, then `curl` handles `POST /v1/payments` and status |

<Note>
  Building the unsigned payment happens client-side in the **SDK or CLI** (`preparePaymentRequest()` resolves gateway defaults; you pass full CAIP-19 asset identifiers). There is no gateway-side build endpoint — the **HTTP tab** covers only submit and status, using a payment you built and signed with the SDK/CLI.
</Note>

For full API details, see the [Payment Gateway API reference](/api-reference/payment-gateway/introduction).

## Prerequisites

The following is required to send a payment:

<AccordionGroup>
  <Accordion title="Node.js 20+ and npm">
    ```bash theme={null}
    brew install node
    ```

    Verify:

    ```bash theme={null}
    node -v   # v20.x.x or newer
    npm -v
    ```
  </Accordion>

  <Accordion title="Access to Atum's Payment Gateway">
    Confirm you can reach the hosted testnet API:

    ```bash theme={null}
    curl -sS https://payment-gw.production-testnet.atum.xyz/v1/defaults | python3 -m json.tool
    ```

    A successful check returns HTTP **200** and the chain defaults for every chain the gateway serves. See [Using the CLI → Access to Atum's Payment Gateway](/get-started/start-building/using-the-cli#prerequisites) for a field-by-field explanation of the response.
  </Accordion>

  <Accordion title="A funded sandbox account">
    Same setup as the CLI quickstart:

    * Testnet ETH on **Arbitrum Sepolia** (for network fees)
    * Testnet USDC on Arbitrum Sepolia
    * **Approve USDC spend** once for that USDC on the source rail (step 3)

    See [Testnet wallet setup](/get-started/reference/setup-testnet-wallet) and [Using the CLI → Approve USDC spend](/get-started/start-building/using-the-cli#3-approve-usdc-spend).
  </Accordion>

  <Accordion title="If using the HTTP API: signing dependencies">
    You cannot sign a payment from `curl` alone. The HTTP tab signs the unsigned
    payment (built with the SDK or CLI) using a short Node script with
    [ethers](https://www.npmjs.com/package/ethers). Run these in your working directory:

    ```bash theme={null}
    npm install ethers
    npm install -D tsx
    ```
  </Accordion>
</AccordionGroup>

## 1. Install the SDK

<Tabs sync={false}>
  <Tab title="SDK">
    Install the published package into your own project:

    ```bash theme={null}
    npm install @atumlabs/payment-gateway-client
    ```
  </Tab>

  <Tab title="HTTP API">
    <Note>
      The gateway has no build endpoint, so the raw-`curl` path still needs the SDK or
      CLI to produce and sign the payment. `curl` then handles `POST /v1/payments` and
      status. Create a working directory, run `npm init`, then install `ethers` and
      `tsx` as described in [Prerequisites](#prerequisites):
    </Note>

    ```bash theme={null}
    mkdir atum-http-demo && cd atum-http-demo
    npm init -y
    ```
  </Tab>
</Tabs>

## 2. Configure addresses and key

Create `.env` in the directory from step 1:

```
PRIVATE_KEY=0x...
SENDER=0x...
RECEIVER=0x...
```

You can use the same `0x` address for `SENDER` and `RECEIVER`.

<AccordionGroup>
  <Accordion title="Using a MetaMask wallet?">
    See [Using the CLI → Configure addresses and key](/get-started/start-building/using-the-cli#2-configure-addresses-and-key) for how to copy your address and export your private key into `.env`.
  </Accordion>
</AccordionGroup>

<Warning>
  Add `.env` to `.gitignore` and **never commit** private keys.
</Warning>

Load variables before running scripts in later steps:

```bash theme={null}
set -a && source .env && set +a
```

## 3. Approve USDC spend

<Note>This step is required for **EVM and Tron** source chains. Solana uses a different token model and does not need this approval.</Note>

Before Atum can move USDC from your wallet during settlement, you must **approve spending once** on the source chain. This is separate from signing each payment.

<AccordionGroup>
  <Accordion title="Already completed Using the CLI?">
    If you ran [Using the CLI → Approve USDC spend](/get-started/start-building/using-the-cli#3-approve-usdc-spend), skip to [step 4](#4-send-your-first-payment).
  </Accordion>

  <Accordion title="Technical name: Permit2">
    Atum uses the standard [Permit2](https://github.com/Uniswap/permit2) contract on EVM chains. Details: [Permit2 and approvals](/get-started/concepts/permit2-and-approvals).
  </Accordion>
</AccordionGroup>

The SDK can do this for you. `ensureSourceApproval` reads the current allowance and only sends a transaction if it falls short, so it is safe to call before every payment:

```typescript theme={null}
import { ethers } from 'ethers';
import { ensureSourceApproval } from '@atumlabs/payment-gateway-client';

// A connected ethers signer. This is the one call in the SDK that sends a
// transaction, so unlike everything else here it needs an RPC endpoint.
// It is NOT the SenderSigner from createSenderSigner, which only signs offline.
// On Tron, pass a TronWeb instance as `tronWeb:` instead — `signer:` is the EVM
// field and only accepts an ethers signer.
const walletSigner = new ethers.Wallet(
  process.env.PRIVATE_KEY!,
  new ethers.JsonRpcProvider('https://sepolia-rollup.arbitrum.io/rpc'),
);

const result = await ensureSourceApproval({
  network: 'eip155:421614',      // CAIP-2 chain id
  token: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', // bare token address
  owner: process.env.SENDER!,    // the paying account, same one you sign with
  signer: walletSigner,
  // requiredAllowance is deliberately omitted: without it only an UNLIMITED
  // approval counts as sufficient, which is the right answer for one-time setup.
  // Do not pass the fulfillment amount here — the escrow pulls max_source_amount,
  // which is that amount plus the protocol fee, so a bounded approval sized to
  // the fulfillment amount reverts on-chain at settlement.
});
console.log(result.alreadySufficient ? 'Already approved.' : `Approved (tx ${result.txHash}).`);
```

This is the only part of the SDK that broadcasts a transaction and spends gas — everything else builds and signs offline. If you are on the MPP path you will see the same helper imported from `@atumlabs/mppx-atum-escrow/client`: both packages re-export it from one shared implementation, so either import gives you the same function — use whichever package you already depend on. `needsSourceApproval` answers the same question read-only, with no key and no gas, so you can warn a payer before asking them to sign. Solana reports that no approval is needed rather than doing nothing.

<AccordionGroup>
  <Accordion title="Prefer a one-off command?">
    The package ships `approve-permit2`, which does the same thing from the terminal once per wallet and token:

    ```bash theme={null}
    npx approve-permit2 $SENDER eip155:421614/erc20:0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d \
      --rpc-url https://sepolia-rollup.arbitrum.io/rpc
    ```

    `--check` reports whether an approval is needed without sending anything or needing a key. See [Using the CLI → Approve USDC spend](/get-started/start-building/using-the-cli#3-approve-usdc-spend).
  </Accordion>

  <Accordion title="Approving by hand instead">
    If you would rather not use Atum's helpers, the approval is an ordinary ERC-20 `approve` of the Permit2 contract for the source token — no Atum-specific call. Grant Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) an allowance on the token you are paying from, from the wallet that holds the funds. You can do it from a block explorer's write tab, or from your own script; [Using the CLI → Approve USDC spend](/get-started/start-building/using-the-cli#3-approve-usdc-spend) has a complete one. The helpers above exist because measuring the existing allowance first is the fiddly part, not because the call is special.
  </Accordion>
</AccordionGroup>

## 4. Send your first payment

With USDC spend approved, write a script that builds your payment, signs it with your wallet, and sends it to the gateway.

### Create script

<Tabs sync={false}>
  <Tab title="SDK">
    Create `send-payment.ts`:

    ```typescript title="send-payment.ts" theme={null}
    import {
      PaymentGatewayClient,
      createSenderSigner,
      signPaymentRequest,
      waitForTerminalStatus,
      assetChainId,
      getErrorResponse,
    } from '@atumlabs/payment-gateway-client';

    const GATEWAY_URL = 'https://payment-gw.production-testnet.atum.xyz';

    const SOURCE_ASSET =
      'eip155:421614/erc20:0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d';
    const DESTINATION_ASSET =
      'eip155:11142220/erc20:0x01C5C0122039549AD1493B8220cABEdD739BC44E';
    const AMOUNT = '10000'; // 0.01 USDC (6 decimals)

    // Your idempotency key: names this ONE payment, so a re-attempt with the SAME
    // requestId resolves onto it instead of creating a second payment. The SDK
    // requires it and never generates one for you — a library-chosen id is an id you
    // cannot reuse on a retry. This sample mints a fresh one per run, so each run is
    // a NEW payment.
    // Set REQUEST_ID to resume a payment that was interrupted while still settling:
    //   REQUEST_ID=<the id printed below> npx tsx send-payment.ts
    const REQUEST_ID = process.env.REQUEST_ID || `sdk-quickstart-${Date.now()}`;

    async function main() {
      const privateKey = process.env.PRIVATE_KEY ?? process.env.ATUM_TESTNET_PRIVATE_KEY;
      const depositor = process.env.SENDER ?? process.env.ATUM_TESTNET_SOURCE_ADDRESS;
      const destination =
        process.env.RECEIVER ?? process.env.ATUM_TESTNET_DESTINATION_ADDRESS;

      if (!privateKey || !depositor || !destination) {
        throw new Error('Set PRIVATE_KEY, SENDER, and RECEIVER in .env');
      }

      const client = new PaymentGatewayClient({ BASE: GATEWAY_URL });

      const paymentRequest = await client.preparePaymentRequest({
        depositor,
        requestId: REQUEST_ID,
        fulfillmentAmount: AMOUNT,
        sourceAsset: SOURCE_ASSET,
        destinationAccount: destination,
        destinationAsset: DESTINATION_ASSET,
      });
      console.log(`Prepared payment request (requestId: ${REQUEST_ID})`);
      console.log(`To retry this exact payment later: REQUEST_ID=${REQUEST_ID} npx tsx send-payment.ts`);

      // pinnedAddress makes the signer refuse if the key does not derive `depositor`.
      // Without it a typo signs as the wrong payer, which the gateway accepts and the
      // chain then reverts at settlement.
      // createSenderSigner takes the CAIP-2 CHAIN id, not the CAIP-19 asset id.
      // assetChainId extracts it: eip155:421614/erc20:0x75f… -> eip155:421614
      const signer = createSenderSigner(assetChainId(SOURCE_ASSET), {
        privateKey,
        pinnedAddress: depositor,
      });
      await signPaymentRequest(paymentRequest, signer);
      console.log('Signed payment authorization');

      const response = await client.payments.submitPayment({ requestBody: paymentRequest });
      console.log('Submitted payment');
      console.log(JSON.stringify(response, null, 2));

      if (!response.payment_id) {
        throw new Error('Submit response missing payment_id');
      }

      // true means this requestId already existed and you were handed the original
      // payment — nothing additional was charged.
      if (response.idempotent_replay) {
        console.log(`Replay: payment ${response.payment_id} already existed for this requestId`);
      }

      if (response.fulfillment_confirmation) {
        console.log('Settlement completed synchronously');
        return;
      }

      // waitForTerminalStatus owns the definition of "terminal" and keeps polling
      // through a failed status check, which is the worst moment to stop looking.
      console.log(`Waiting on payment_id ${response.payment_id}…`);
      const outcome = await waitForTerminalStatus({
        fetchStatus: () => client.payments.getPaymentStatus({ paymentId: response.payment_id! }),
        budgetMs: 120_000,
      });

      // Exit codes match the CLI's, so either tool can drive the same script:
      // 0 settled, 2 still settling (NOT a failure), 3 terminal failure.
      if (outcome.timedOut) {
        console.log(`Still settling — re-run with REQUEST_ID=${REQUEST_ID} to resume.`);
        process.exit(2);
      }
      console.log(JSON.stringify(outcome.snapshot, null, 2));
      if (outcome.snapshot?.status !== 'completed') {
        // `waitForTerminalStatus` only returns on `completed` or `failed`, so this
        // payment can never settle. A fresh attempt needs a NEW requestId.
        console.error(`Payment ${outcome.snapshot?.status} — this is final.`);
        process.exit(3);
      }
    }

    main().catch((err) => {
      // Gateway refusals carry a typed body: branch on `code`, not on the message text.
      const gatewayError = getErrorResponse(err);
      console.error(gatewayError ?? err);
      process.exit(1);
    });
    ```

    <AccordionGroup>
      <Accordion title="What's happening?">
        **The short version:** Your script builds a payment, signs it with your wallet, sends it to the gateway, then checks until USDC moves or the payment fails. Same four steps as the CLI.

        **What the script does:**

        1. **Build the payment** — `preparePaymentRequest()` gathers route details and creates an unsigned payment your wallet still needs to sign. `requestId` is your idempotency key — see below
        2. **Sign locally** — `createSenderSigner()` builds a signer from your private key and `signPaymentRequest()` applies it. Signing happens on your machine; the gateway never sees your key
        3. **Send to the gateway** — `submitPayment()` posts the signed payment. A `payment_id` in the response means the gateway **accepted** it — USDC has **not** moved yet
        4. **Wait for the outcome** — `waitForTerminalStatus()` polls `getPaymentStatus()` until the payment is terminal, or until the budget runs out

        **Remember:**

        * **Approve USDC spend** (step 3 in this guide) is one-time setup — separate from signing each payment
        * A `payment_id` only proves submit worked — use step 5 to confirm settlement if you skip the wait in the sample script
        * **A wait that times out is not a failure.** `outcome.timedOut` means the payment is still live and has outrun your budget; re-run under the same `requestId` or read `/status` later. `completed` and `failed` are the only statuses `waitForTerminalStatus()` stops on
        * **`requestId` is what makes a retry safe.** It names one **payment**, not one attempt — every attempt at that payment reuses it. The sample mints `sdk-quickstart-<timestamp>` per run, so **every run is a new payment**; that's fine for repeated testnet runs, but in your own integration derive it from something stable per payment (an order id, a job id) so a crashed or timed-out call can be retried under the *same* `requestId` instead of accidentally paying twice. `idempotent_replay: true` on the response tells you that happened. See [Idempotency](/payment-protocols/idempotency) for the full guarantees and the `409 IDEMPOTENCY_TERMS_MISMATCH` you get back if a retry's terms don't match the original.

        <AccordionGroup>
          <Accordion title="Why not sign the message yourself?">
            Do **not** call `wallet.signingKey.sign(message)` on `signed_messages[0].message`. The gateway expects a typed signature built from `message_prehash`, and the shape differs per chain. `createSenderSigner()` picks the right one for the source chain — EVM, Tron, or Solana — so this is the only signing surface you need. Pass `{ provider: 'turnkey', ... }` instead of `privateKey` to sign with a Turnkey wallet.
          </Accordion>

          <Accordion title="Technical note: Permit2 and EIP-712">
            Step 1 fetches chain defaults and embeds Permit2 signing data in `message_prehash`. Step 2 calls `wallet.signTypedData(...)`. See [Permit2 and approvals](/get-started/concepts/permit2-and-approvals).
          </Accordion>

          <Accordion title="Asset IDs and amount">
            The script pins one testnet route so you can focus on the flow:

            * **`SOURCE_ASSET`** — USDC on Arbitrum Sepolia (`eip155:421614/erc20:0x75faf1…`)
            * **`DESTINATION_ASSET`** — USDC on Celo Sepolia (`eip155:11142220/erc20:0x01C5C012…`)
            * **`AMOUNT`** — `10000` = **0.01 USDC** (6-decimal atomic units, not dollars)

            Copy other testnet asset IDs from [Supported assets](/get-started/reference/supported-assets). Format explained in [Corridors and assets](/get-started/concepts/corridors-and-assets).

            <AccordionGroup>
              <Accordion title="Technical name: CAIP-19">
                Asset IDs follow the [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) standard.
              </Accordion>
            </AccordionGroup>
          </Accordion>
        </AccordionGroup>
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="HTTP API">
    Submit uses **`curl`**, but signing the payment (built with the SDK/CLI) must happen in a small helper script — not in the shell:

    ```typescript title="sign-payment-request.ts" theme={null}
    import { readFileSync, writeFileSync } from 'node:fs';
    import { ethers } from 'ethers';

    async function main() {
      const inputPath = process.argv[2] ?? 'unsigned-payment.json';
      const outputPath = process.argv[3] ?? 'signed-payment.json';

      const raw = JSON.parse(readFileSync(inputPath, 'utf8'));
      const paymentRequest = raw.payment_request ?? raw;

      const privateKey = process.env.PRIVATE_KEY ?? process.env.ATUM_TESTNET_PRIVATE_KEY;
      if (!privateKey) throw new Error('Set PRIVATE_KEY in .env');

      const signedMessage = paymentRequest.sender_auth.signed_messages[0];
      if (!signedMessage.message_prehash) {
        throw new Error('Unsigned payment missing message_prehash');
      }

      const prehash = JSON.parse(signedMessage.message_prehash);
      const wallet = new ethers.Wallet(privateKey);
      signedMessage.signature = await wallet.signTypedData(
        prehash.domain,
        prehash.types,
        prehash.message,
      );

      writeFileSync(outputPath, JSON.stringify(paymentRequest, null, 2));
      console.log(`Wrote signed request to ${outputPath}`);
    }

    main().catch((err) => {
      console.error(err);
      process.exit(1);
    });
    ```

    <AccordionGroup>
      <Accordion title="What's different from the SDK path?">
        The unsigned payment is **always** built client-side — the gateway has no build
        endpoint. So this HTTP path still uses the SDK or CLI to assemble the payment
        (`preparePaymentRequest()` resolves gateway defaults; you supply full CAIP-19
        asset identifiers). What `curl` replaces is only the transport for the last two steps:

        1. **Build the payment** — `preparePaymentRequest()` (SDK) or `send-payment` (CLI), on your machine
        2. **Sign locally** — `npx tsx sign-payment-request.ts` or `createSenderSigner()` + `signPaymentRequest()` (SDK)
        3. **Send to the gateway** — `curl` → `POST /v1/payments`, or `submitPayment()` (SDK)

        See the [Payment Gateway API reference](/api-reference/payment-gateway/introduction).
      </Accordion>

      <Accordion title="Why not curl for signing?">
        The gateway returns data your wallet must sign in `sender_auth.signed_messages[0].message_prehash`. `curl` has no wallet — use any language with a signing library; this guide uses Node + `ethers`.

        <AccordionGroup>
          <Accordion title="Technical name: Permit2 EIP-712">
            On EVM this is a Permit2 typed signature. See [Permit2 and approvals](/get-started/concepts/permit2-and-approvals).
          </Accordion>
        </AccordionGroup>
      </Accordion>
    </AccordionGroup>
  </Tab>
</Tabs>

### Run it

Load your `.env` if you have not already:

```bash theme={null}
set -a && source .env && set +a
```

<Tabs sync={false}>
  <Tab title="SDK">
    From your project directory:

    ```bash theme={null}
    npm install -D tsx
    npx tsx send-payment.ts
    ```

    You should see output similar to:

    ```
    Prepared payment request (requestId: sdk-quickstart-1799376958669)
    To retry this exact payment later: REQUEST_ID=sdk-quickstart-1799376958669 npx tsx send-payment.ts
    Signed payment authorization
    Submitted payment
    {
      "payment_id": "0xbea7...d2c3",
      "status": "pending",
      "idempotent_replay": false
    }
    Waiting on payment_id 0xbea7...d2c3…
    {
      "payment_id": "0xbea7...d2c3",
      "status": "completed",
      "updated_at": "2026-07-07T00:55:58.669124Z",
      "fulfillment_confirmation": {
        "source_chain_id": "eip155:421614",
        "source_tx_hash": "0xb6109bda6736369dd459fbfedc6437a19b360b37a52b29bde6202fcb8295c3d0",
        "source_block_number": 284887341,
        "destination_chain_id": "eip155:11142220",
        "destination_tx_hash": "0xbc1a450da031caedbb1ae30bdee8c2144202eb357991764aebd88c1849b70e38"
      }
    }
    ```

    A `payment_id` in the submit response means the gateway **accepted** your signed payment — not that settlement finished. The SDK script keeps checking status until USDC moves or the payment fails.

    <AccordionGroup>
      <Accordion title="Amount (atomic units)">
        `fulfillmentAmount` is **not** dollars — it is USDC in **6-decimal atomic units** (smallest on-chain increment):

        | You send  | SDK value |
        | --------- | --------- |
        | 0.01 USDC | `10000`   |
        | 0.10 USDC | `100000`  |
        | 1.00 USDC | `1000000` |

        Use a **small test amount** (for example `10000`) to conserve testnet USDC.
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="HTTP API">
    <Note>
      There is no HTTP build endpoint, so this path still starts with the SDK or CLI to
      produce the unsigned payment. `curl` handles only the final submit and status.
    </Note>

    From your working directory:

    **1. Build** — assemble the unsigned payment with the SDK (`preparePaymentRequest()`, see the SDK tab) or the [CLI](/get-started/start-building/using-the-cli), and save it as `unsigned-payment.json`. You pass full CAIP-19 asset identifiers; the client resolves the remaining gateway defaults.

    **2. Sign** — produce the payment authorization signature:

    ```bash theme={null}
    npx tsx sign-payment-request.ts
    ```

    **3. Submit** — `curl` posts the signed `PaymentRequest`:

    ```bash theme={null}
    curl -sS -X POST https://payment-gw.production-testnet.atum.xyz/v1/payments \
      -H "Content-Type: application/json" \
      -d @signed-payment.json | python3 -m json.tool
    ```

    You should see a JSON object with `payment_id`. A `payment_id` means the gateway **accepted** your signed request — not that settlement finished.

    Continue to [Try it (CLI) → Check payment status](/get-started/start-building/using-the-cli#5-check-payment-status) with the `payment_id` from the submit response.
  </Tab>
</Tabs>

The SDK script polls and prints the final status automatically — when you see `"status": "completed"` with both tx hashes, you're done.

If you want to check a payment manually (e.g. from a previous run):

```bash theme={null}
PAYMENT_ID=0x...   # paste your payment_id

curl -sS "https://payment-gw.production-testnet.atum.xyz/v1/payments/${PAYMENT_ID}/status" | python3 -m json.tool
```

Optional — event timeline for debugging:

```bash theme={null}
curl -sS "https://payment-gw.production-testnet.atum.xyz/v1/payments/${PAYMENT_ID}/timeline" | python3 -m json.tool
```

| `status`    | Meaning                                                |
| ----------- | ------------------------------------------------------ |
| `pending`   | Accepted, settlement still running — keep waiting      |
| `completed` | Payment fulfilled — settlement confirmed on both rails |
| `failed`    | Settlement did not complete — check `error.code`       |

`isTerminalStatus()` returns `true` for `completed` and `failed`, and nothing else. The package also declares `finalizing`, for a delivery transaction that is on chain but still gathering confirmations — nothing emits it yet, it is not terminal, and an exhaustive `switch` on `PaymentStatus` needs a branch for it.

For status meanings, failure modes, and the troubleshooting table, see [Try it (CLI) → Check payment status](/get-started/start-building/using-the-cli#5-check-payment-status) — same statuses apply.

## Success criteria

You completed this guide when:

* [ ] `GET /v1/defaults` returns chain defaults for the testnet gateway
* [ ] Approve USDC spend confirmed on Arbitrum Sepolia
* [ ] Build step returns an unsigned payment with signing data (`preparePaymentRequest` in the SDK, or the CLI)
* [ ] Submit returns a `payment_id` (SDK: `submitPayment`; HTTP: `POST /v1/payments`)
* [ ] `GET /v1/payments/{payment_id}/status` returns `"status": "completed"` with `source_tx_hash` and `destination_tx_hash`

The first four items confirm your **integration**. The last confirms **settlement** — verify asset IDs if you see `NO_QUOTES_RECEIVED`; that does not invalidate the first four.

## Next steps

| Topic                         | Guide                                                              |
| ----------------------------- | ------------------------------------------------------------------ |
| CLI walkthrough (no app code) | [Using the CLI](/get-started/start-building/using-the-cli)         |
| Payment Gateway API reference | [Payment Gateway API](/api-reference/payment-gateway/introduction) |
| Testnet chains and asset IDs  | [Supported networks](/get-started/reference/supported-networks)    |
| On-chain contract addresses   | [Contract addresses](/get-started/reference/contract-addresses)    |
| Key terms                     | [Core concepts](/get-started/architecture/core-concepts)           |
