# Quickstart

Make your first payout in the sandbox (Multigate's test environment, where no real money moves), end to end: set up your API key, register a webhook, fund an account, send money, and receive the result. The commands take about 15 minutes; the sandbox can then take 15 minutes or more to report the payout's result.

:::warning[Sandbox behaves differently in places]
In the sandbox, payout results can take 15 minutes or more to arrive, and some test banks pick outcomes at random. A few production checks are also off. See [Sandbox vs production](05-sandbox-vs-production.md).
:::

## Before you start

Collect these first. Every command below uses them.

| You need | Where you get it |
|---|---|
| **A sandbox API key** | An API key is the secret your software sends on every request. In the sandbox dashboard ([uat.multigate.app](https://uat.multigate.app)), go to **Developer Tool › API Configuration › API keys**, click **Generate API key** and name it (for example "Quickstart"). The key is shown **once**: copy it straight away. Only dashboard users with the *refresh client API credentials* permission can generate keys; otherwise ask your administrator. See [Get an API key](06-authentication.md#get-an-api-key). |
| **Client secret** | Same page, shown as the *secret key*. You don't need it for the steps below: it signs the webhooks Multigate sends you, so you need it when you [verify them](../03-webhooks-and-emails/03-verifying-signatures.md). See [Your credentials](06-authentication.md#your-credentials). |
| **Permissions** | The API user your key belongs to needs: *refresh client API credentials* (to register the webhook), *view accounts*, and *initiate payment*. Ask your dashboard administrator or Multigate contact to add any that are missing. Step 2 shows how to check. |
| **Your public IP address on the allow-list** (only if your business uses one) | An IP allow-list is a list of server addresses allowed to use your business's API key. If step 2 (or any later step) returns `403` "Unauthorized access", ask your administrator to add your public IP address under **Developer Tool › API Configuration › IP Whitelist**. Find your address with `curl -s https://checkip.amazonaws.com`. |
| **A public HTTPS URL for webhooks** | A webhook is an HTTPS request Multigate sends to your server when something happens. See [Receive webhooks on your laptop](#receive-webhooks-on-your-laptop) below. |
| **Tools** | A terminal with `curl` and [`jq`](https://jqlang.org) (a command-line JSON tool). |

You don't need your client ID yet: step 2 finds it for you.

### Receive webhooks on your laptop

Multigate can only reach a public HTTPS URL. To test from your laptop, run a small receiver and expose it with a tunnel.

1. Save this as `webhook-receiver.mjs` and run `node webhook-receiver.mjs`. It prints every event and answers `200`.

```javascript
import { createServer } from 'node:http';

createServer((req, res) => {
  let body = '';
  req.on('data', (chunk) => { body += chunk; });
  req.on('end', () => {
    console.log(new Date().toISOString(), req.headers['idempotency-key'], body);
    res.writeHead(200).end();
  });
}).listen(3000, () => console.log('Listening on http://localhost:3000'));
```

2. In another terminal, start a tunnel to port 3000 with a tunnelling tool such as `ngrok http 3000` or `cloudflared tunnel --url http://localhost:3000`. It prints a public `https://…` address. Use it as your webhook URL.

If you restart the tunnel and its address changes, register the new address again (step 3). Use a tunnel for sandbox testing only.

<Steps>
<Step title="Set up your environment">

Put your sandbox values in environment variables, so every command below works as written. Use **single quotes**, so the shell doesn't change any character.

```bash
export MG_BASE_URL='https://api-gateway-uat.mmacs.io/platform'
export MG_API_KEY='paste-your-sandbox-api-key'   # starts with mgk_sandbox_
export MG_WEBHOOK_URL='https://your-tunnel-address.example/webhooks/multigate'
```

These names are used throughout the docs: `MG_BASE_URL` is the sandbox address that every path is added to, `MG_API_KEY` is sent in the `mp-api-key` header of every request, and the next steps add `MG_CLIENT_ID`, `MG_ACCOUNT_ID`, `MG_VIRTUAL_ACCOUNT_NUMBER`, `MG_ACCOUNT_NUMBER` and `MG_REQUEST_ID`.

Typing a key into a terminal can leave it in your shell history. That's acceptable for a sandbox key on your own computer; in your own code, load the key from a secrets manager.

</Step>
<Step title="Check your key and find your client ID">

The API key needs no sign-in and doesn't expire: send it as it is. Check that it works, find your **client ID** (the ID of your business on Multigate) and check your permissions:

```bash
CURRENT_USER=$(curl -s "$MG_BASE_URL/user-service/api/v1/users/current-user" \
  -H "mp-api-key: $MG_API_KEY")
export MG_CLIENT_ID=$(echo "$CURRENT_USER" | jq -r '.currentAuthenticatedClientId')
echo "$CURRENT_USER" | jq '{currentAuthenticatedClientId, permissions: [.permissions[].permissionId]}'
```

```json
{
  "currentAuthenticatedClientId": "Ab3kP9xQ2mN7vR1sT4uV6",
  "permissions": ["can_initiate_payment", "can_refresh_client_api_credentials", "can_view_accounts"]
}
```

Check that the list contains `can_refresh_client_api_credentials`, `can_view_accounts` (or `manage_accounts`) and `can_initiate_payment`.

| If you see | Cause | Fix |
|---|---|---|
| `401` "Authentication failed" | `MG_API_KEY` is empty, mistyped or cut short (a key is 58 characters), it is a production key used in the sandbox, or it was revoked. | Run `echo ${#MG_API_KEY}` (it should print `58`), paste the key again, and check it is listed as active in the sandbox dashboard. If you lost it, generate a new one. |
| `401` "Authentication failed" with a correct key | The request also sends an `Authorization` header (for example from an old shell function or an HTTP client default). | Send only `mp-api-key`. |
| `403` "Unauthorized access" (`code` `098`) here, or `401` on a later step although step 2 worked from another computer | Your business has an IP allow-list and this computer's address isn't on it. The user service (this step) answers `403`; the other services answer `401`. | Ask your administrator to add your public IP address (see [Before you start](#before-you-start)). If a key that works from one server gets 401 from another, check that server's address is on your allow-list. |
| `jq` prints an error, or `currentAuthenticatedClientId` is `null` | The response wasn't the user: usually one of the errors above. | Run the `curl` on its own, without the `jq` part, to see the full error. |

More causes: [When a request returns 401](06-authentication.md#when-a-request-returns-401).

</Step>
<Step title="Register your webhook URL">

Tell Multigate where to send events. Do this before anything else: events that happen while no URL is registered are lost.

```bash
curl -s -X POST "$MG_BASE_URL/client-service/api/v1/client-configurations" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg clientId "$MG_CLIENT_ID" --arg webhookUrl "$MG_WEBHOOK_URL" \
        '{mode: "sandbox", clientId: $clientId, webhookUrl: $webhookUrl}')" \
  | jq '{message, mode, webhookUrl}'
```

```json
{ "message": "Client configuration updated successfully", "mode": "sandbox", "webhookUrl": "https://your-tunnel-address.example/webhooks/multigate" }
```

`mode` is only a label. Your business has **one** webhook URL per environment: the last configuration saved wins. If your business already saved one (check with [Get client configurations](api:getClientConfigurations)), reuse its `mode`. Don't print the full response: it contains your client key and secret in encrypted form.

| If you see | Cause | Fix |
|---|---|---|
| `404` "Resource not found" | Your API user lacks the *refresh client API credentials* permission. | Ask your administrator to add it, or set the URL in the dashboard under **Developer Tool › API Configuration**. |
| `404` "Client not found" | `MG_CLIENT_ID` is empty or wrong. | Run step 2 again and check that `MG_CLIENT_ID` isn't empty. |

</Step>
<Step title="Find an account to pay from">

An **account** (also called a *wallet*) holds a balance in one currency. Payers fund it through its **virtual accounts**: real bank account numbers, listed in `fundingDetails`, that credit the wallet. List your active NGN wallets:

```bash
ACCOUNTS=$(curl -s "$MG_BASE_URL/client-service/api/v1/accounts?currencies=NGN&statuses=active&accountTypes=wallet&pageSize=20" \
  -H "mp-api-key: $MG_API_KEY")
echo "$ACCOUNTS" | jq '.data[] | {accountId, accountName, virtualAccountNumbers: [.fundingDetails[].accountNumber]}'
```

```json
{
  "accountId": "Ac4mW8pZ2kR6tY1nQ7xVb",
  "accountName": "Acme Payments Ltd",
  "virtualAccountNumbers": ["9912345678"]
}
```

Keep the first wallet that has a virtual account number:

```bash
export MG_ACCOUNT_ID=$(echo "$ACCOUNTS" | jq -r '[.data[] | select(.fundingDetails | length > 0)][0].accountId')
export MG_VIRTUAL_ACCOUNT_NUMBER=$(echo "$ACCOUNTS" | jq -r '[.data[] | select(.fundingDetails | length > 0)][0].fundingDetails[0].accountNumber')
echo "$MG_ACCOUNT_ID $MG_VIRTUAL_ACCOUNT_NUMBER"
```

| If you see | Cause | Fix |
|---|---|---|
| `[{"errors":{"message":"account not found"}}]`, or `jq` prints an error or `null` | You have no active NGN wallet with a virtual account yet. | Ask your Multigate contact to open one in the sandbox, or create one with [Create an account](api:createAccount) and wait for the `account` / `account_opened` webhook. |
| "Resource not found" | Your API user lacks the *view accounts* permission. | Ask your administrator to add it. |

</Step>
<Step title="Fund it with a simulated inflow">

An **inflow** is money received into an account. In the sandbox, you add money by simulating a payment into the virtual account number:

```bash
curl -s -X POST "$MG_BASE_URL/client-service/api/v1/webHook/publishInflow" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"accountNumber\":\"$MG_VIRTUAL_ACCOUNT_NUMBER\",\"amount\":1000000,\"currency\":\"NGN\"}"
```

```json
{ "message": "inflow initiated" }
```

`amount` is in naira (major units): `1000000` is ₦1,000,000.00. The response only means the inflow was queued. It is then screened and credited, which can take a few minutes. Your webhook receiver then prints an event whose `header.notification_type` is `account` and `header.notification` is `inflow`, and your business contact email receives a "Payment Received" email.

| If you see | Cause | Fix |
|---|---|---|
| `200` with `[{"errors":{"message":"account has not been approved"}}]` | The account isn't active yet. | Wait for the `account_opened` webhook and retry. |
| `404` "account not found" | The number isn't one of your virtual account numbers. | Use a number from `fundingDetails` (step 4). |

</Step>
<Step title="Check the balance">

Wait for the `inflow` webhook, then check the balance. This also gives you the wallet's **own account number** (`accountNumber`), which this Quickstart pays from. (A virtual account number also works as the source: Multigate debits the wallet it belongs to.)

```bash
curl -s "$MG_BASE_URL/client-service/api/v1/accounts/NGN/balance?entityId=$MG_ACCOUNT_ID" \
  -H "mp-api-key: $MG_API_KEY" | jq '.accountBalance'
export MG_ACCOUNT_NUMBER=$(curl -s "$MG_BASE_URL/client-service/api/v1/accounts/NGN/balance?entityId=$MG_ACCOUNT_ID" \
  -H "mp-api-key: $MG_API_KEY" | jq -r '.accountBalance.accountNumber')
```

```json
{ "accountNumber": "1029384756", "availableBalance": "1000000", "bookBalance": "1000000" }
```

`availableBalance` is what you can spend now. It must cover the payout plus its fee (₦25,000 plus about ₦50 below).

- **Balance is `"10000"` instead of `"1000000"`?** For some sandbox accounts the simulated amount is read in kobo (hundredths of a naira) and credited at one hundredth. Repeat step 5 with `100000000`.
- **Balance is `"0"`?** The inflow hasn't been credited yet, or the balance couldn't be read. Wait a minute and check again.

</Step>
<Step title="Send a payout">

Every payout carries a `uniqueRequestId`: your own unique ID for this payment. If a request times out, you look the payment up by this ID instead of sending it again, so it is never paid twice ([Idempotency](../02-concepts/03-idempotency.md)). It must be unique across all of Multigate, so include something of your own. Send ₦25,000 to a test beneficiary (the person or business you pay):

```bash
export MG_REQUEST_ID="qs_${MG_CLIENT_ID}_$(date +%s)"
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/single-transfer" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"uniqueRequestId\": \"$MG_REQUEST_ID\",
    \"reference\": \"Quickstart payout\",
    \"amount\": 25000,
    \"sourceCurrencyIso3Code\": \"NGN\",
    \"sourceAccountNumber\": \"$MG_ACCOUNT_NUMBER\",
    \"beneficiaryDetails\": {
      \"name\": \"Ada Okafor\",
      \"accountName\": \"Ada Okafor\",
      \"accountNumber\": \"0123456789\",
      \"bankBicOrCode\": \"058\",
      \"currencyIso3Code\": \"NGN\",
      \"countryIso3Code\": \"NGA\",
      \"addressLine1\": \"12 Marina Road\",
      \"city\": \"Lagos\",
      \"state\": \"Lagos\"
    }
  }" | jq '{transactionId, uniqueRequestId, status, amount, charge}'
```

`bankBicOrCode` is the beneficiary bank's code (`058` is a Nigerian bank code; see [List banks](api:listBanks)). `reference` needs at least 5 characters.

```json
{
  "transactionId": "PMT-261010-093000482913",
  "uniqueRequestId": "qs_Ab3kP9xQ2mN7vR1sT4uV6_1791660000",
  "status": "pending",
  "amount": "25000.00",
  "charge": "50.00"
}
```

**Store** `transactionId` (Multigate's ID for the payout) with your `uniqueRequestId`. The payout isn't finished yet: `pending` means it is being screened, and it then moves to `processing` and finally `completed` or `failed`. The fee (`charge`) is taken from your account on top of `amount`.

| If you see | Cause | Fix |
|---|---|---|
| `status` `pending_approval` | Your API user can't approve its own payments, so an approver in your business must approve it. | Approve it in the dashboard, or ask Multigate to give your sandbox API user the *approve payment* permission too. |
| Later, `status` `failed` with `responseCode` `22` (and a `transfer_failed` webhook) | The balance didn't cover amount plus fee when Multigate checked it, just before the debit. Nothing was debited. | Fund the account (step 5), check the balance (step 6), then send a new payout with a new `uniqueRequestId`. |
| `400` "Invalid Account Type" | The account found can't be debited (it isn't a wallet or a linked account). | Use `MG_ACCOUNT_NUMBER` from step 6, or send `"sourceAccountId": "$MG_ACCOUNT_ID"` instead. |
| `400` "Unique request ID already exist" | This `uniqueRequestId` was used before. | Run the `export MG_REQUEST_ID=…` line again for a new ID. |
| `400` "failed_validation" with an `errors` list | A field is missing or invalid; `errors` names it. | Fix the field. |
| A timeout, `500` or `502` | The outcome is unknown: the payout may exist. | Look it up (step 8) before you send anything again. |

All payout errors: [Payouts](../04-products/05-payouts.md).

</Step>
<Step title="Get the result">

Your webhook receiver gets an event with `header.notification_type` `transfer` and `header.notification` `transfer_completed` (or `transfer_failed`), carrying the same `uniqueRequestId`. In the sandbox this can take **15 minutes or more**, and the test bank decides whether it completes or fails. Both are fine for this test.

You can also check at any time:

```bash
curl -s "$MG_BASE_URL/payment-service/api/v1/transaction/report?uniqueRequestId=$MG_REQUEST_ID" \
  -H "mp-api-key: $MG_API_KEY" | jq '.data[0] | {transactionId, status, responseCode, responseMessage}'
```

```json
{ "transactionId": "PMT-261010-093000482913", "status": "completed", "responseCode": "00", "responseMessage": "Successful" }
```

`completed` and `failed` are final. A payout that stays `pending` or `processing` isn't failed: keep waiting, or see [Transfer statuses](../04-products/05-payouts.md#transfer-statuses).

</Step>
</Steps>

:::check[You've made your first payout]
You called the API with your key, registered a webhook, funded an account and paid a beneficiary. Everything else in the API follows the same pattern: send a request with a `uniqueRequestId`, store the IDs, and wait for the webhook.
:::

## What to build next

<CardGroup cols={2}>
<Card title="Handle webhooks properly" icon="lucide-shield-check" href="../03-webhooks-and-emails/03-verifying-signatures.md">
Verify signatures, acknowledge fast, de-duplicate.
</Card>
<Card title="Look after your API key" icon="lucide-key-round" href="06-authentication.md#rotate-an-api-key">
Store it in a secrets manager, and rotate it without downtime.
</Card>
<Card title="Retry safely" icon="lucide-repeat" href="../02-concepts/03-idempotency.md">
Never pay twice when a request times out.
</Card>
<Card title="Payouts in depth" icon="lucide-send" href="../04-products/05-payouts.md">
Statuses, rails, approvals, split transfers and errors.
</Card>
</CardGroup>

When your integration works in the sandbox, read [Sandbox vs production](05-sandbox-vs-production.md) and the [go-live checklist](07-go-live-checklist.md).
