Get started
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.
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.
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), 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. |
| 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. See 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 below. |
| Tools | A terminal with curl and jq (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.
- Save this as
webhook-receiver.and runmjs node webhook-receiver.mjs. It prints every event and answers200.
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'));
- In another terminal, start a tunnel to port 3000 with a tunnelling tool such as
ngrok http 3000orcloudflared tunnel --url http://localhost:3000. It prints a publichttps://…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.
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.
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_, MG_ 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.
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:
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]}'
{
"currentAuthenticatedClientId": "Ab3kP9xQ2mN7vR1sT4uV6",
"permissions": ["can_initiate_payment", "can_refresh_client_api_credentials", "can_view_accounts"]
}
Check that the list contains can_, can_ (or manage_) and can_.
| 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). 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 current 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.
Tell Multigate where to send events. Do this before anything else: events that happen while no URL is registered are lost.
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}'
{ "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), 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. |
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 funding, that credit the wallet. List your active NGN wallets:
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]}'
{
"accountId": "Ac4mW8pZ2kR6tY1nQ7xVb",
"accountName": "Acme Payments Ltd",
"virtualAccountNumbers": ["9912345678"]
}
Keep the first wallet that has a virtual account number:
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 and wait for the account / account_ webhook. |
| "Resource not found" | Your API user lacks the view accounts permission. | Ask your administrator to add it. |
An inflow is money received into an account. In the sandbox, you add money by simulating a payment into the virtual account number:
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\"}"
{ "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. is account and header. 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_ webhook and retry. |
404 "account not found" |
The number isn't one of your virtual account numbers. | Use a number from funding (step 4). |
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.)
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')
{ "accountNumber": "1029384756", "availableBalance": "1000000", "bookBalance": "1000000" }
available 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 with100000000. - Balance is
"0"? The inflow hasn't been credited yet, or the balance couldn't be read. Wait a minute and check again.
Every payout carries a unique: 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). 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):
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). reference needs at least 5 characters.
{
"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 unique. 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_ |
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_ 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 unique. |
400 "Invalid Account Type" |
The account found can't be debited (it isn't a wallet or a linked account). | Use MG_ from step 6, or send "sourceAccountId": "$MG_ACCOUNT_ID" instead. |
400 "Unique request ID already exist" |
This unique 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.
Your webhook receiver gets an event with header. transfer and header. transfer_ (or transfer_), carrying the same unique. 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:
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}'
{ "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.
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 unique, store the IDs, and wait for the webhook.
What to build next
Verify signatures, acknowledge fast, de-duplicate.
Store it in a secrets manager, and rotate it without downtime.
Never pay twice when a request times out.
Statuses, rails, approvals, split transfers and errors.
When your integration works in the sandbox, read Sandbox vs production and the go-live checklist.