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

# Set up local development

> Configure sandbox access, check your credentials, and receive webhooks on your machine.

Local development means running your application on your own machine against the sandbox, with your debugger attached.
When you're ready to run on deployed infrastructure, see [Testing stages](/tabs/developer-documentation/testing/overview.md#testing-stages).

## Get sandbox access

1. Ask your Payward integration contact to invite you to the
   [sandbox Console](https://console.sandbox.services.payward.com), at `https://console.sandbox.services.payward.com`. Also ask them which capabilities are enabled for your account, such
   as hosted verification or crypto deposits and withdrawals.
2. In the Console, open **API keys** and create a key, as described in
   [Authentication](/tabs/developer-documentation/get-started/authentication.md#setting-the-api-key-parameter). Select the
   scopes for the flows you'll test. Each scope's description in the Console says what it allows, such as
   `add-funds` (deposit funds) or `withdraw-funds`. If a call returns `403`, check the key's scopes.
3. Ask your Payward contact to allowlist the public IP addresses your developers call the sandbox from, such as your
   office or VPN egress addresses. Payward blocks API requests from other addresses.
4. Optionally, restrict the key itself in the Console's **IP Whitelist** field. Leave it empty to allow any address that
   Payward has allowlisted.

## Find your account ID

Most endpoints act on an account, identified by `account_id`. The Console's **Configuration** card shows a **Client ID**,
such as `AA85 N84G F6ZU IQSY`. That's your *user* ID, not your account ID.

To get your account ID, make a signed call to [**Get user**](/api-reference/users/get-user.md) (`GET /v1/users/{user_id}`) with the Client ID, without
spaces: `GET /v1/users/{client_id}`. The response lists your accounts in [`accounts`](/api-reference/users/get-user#response-data-accounts). Use the `id` of the account named
`main` as `account_id`. See [Users, accounts, and ownership](/tabs/concepts/users-and-accounts.md#user-vs-account).

The script in [Check your credentials](#check-your-credentials) makes this call for you and prints the account ID.

## Configure your client

Store these values in your local environment or a secrets manager.

```bash theme={null}
export PWS_BASE_URL='https://api.sandbox.services.payward.com'
export PWS_API_KEY='REPLACE_WITH_SANDBOX_API_KEY'
export PWS_API_SECRET='REPLACE_WITH_SANDBOX_API_SECRET'
export PWS_CLIENT_ID='REPLACE_WITH_CONSOLE_CLIENT_ID_WITHOUT_SPACES'
```

After you [check your credentials](#check-your-credentials), also set `PWS_ACCOUNT_ID` to the account ID that the
check prints. The examples in other guides read it.

The [Authentication](/tabs/developer-documentation/get-started/authentication.md) examples use the same variables but call
production. To run them against the sandbox, read `BASE_URL` from `PWS_BASE_URL`.

## Check your credentials

Make three read-only calls:

1. [**List webhooks**](/api-reference/webhooks/list-webhooks.md) (`GET /v1/webhooks`) checks your key, secret, and signing
   code. It needs no account ID, and a new integration gets `{"data":[]}`.
2. [**Get user**](/api-reference/users/get-user.md) (`GET /v1/users/{client_id}`) returns your account IDs.
3. [**Get portfolio**](/api-reference/portfolio/get-portfolio.md) (`GET /v1/accounts/{account_id}/portfolio`) checks your
   access to the `main` account.

> [!NOTE]
>
> Public endpoints, such as [**List assets**](/api-reference/assets/list-assets.md) (`GET /v1/assets`), don't check
> signatures. A successful response from them doesn't confirm that your key, secret, or signing code works.

Save the example as `check_credentials.py` or `check-credentials.mjs`. It follows the signing steps in
[Authentication](/tabs/developer-documentation/get-started/authentication.md), including the `next_nonce()` (`nextNonce()`)
helper that keeps nonces increasing.

```python Python theme={null}
import base64
import hashlib
import hmac
import json
import os
import time
from urllib.parse import quote, urlparse

import requests

base_url = os.environ["PWS_BASE_URL"]
api_key = os.environ.get("PWS_API_KEY", "")
secret = os.environ.get("PWS_API_SECRET", "")
client_id = os.environ.get("PWS_CLIENT_ID", "").replace(" ", "")

if any(not value or "replace_with" in value.lower() for value in (api_key, secret, client_id)):
    raise SystemExit("Replace every configuration placeholder before running the example.")
if urlparse(base_url).hostname != "api.sandbox.services.payward.com":
    raise SystemExit("This example runs only against the sandbox.")

# Nonces must increase for each API key. Use nanoseconds since the Unix epoch, and never repeat a value.
last_nonce = 0


def next_nonce():
    global last_nonce
    last_nonce = max(time.time_ns(), last_nonce + 1)
    return str(last_nonce)


def sign(path, nonce, body=""):
    digest = hashlib.sha256((nonce + body).encode()).digest()
    mac = hmac.new(base64.b64decode(secret), path.encode() + digest, hashlib.sha512)
    return base64.b64encode(mac.digest()).decode()


def signed_get(path):
    nonce = next_nonce()
    response = requests.get(
        base_url.rstrip("/") + path,
        headers={"API-Key": api_key, "API-Nonce": nonce, "API-Sign": sign(path, nonce)},
        allow_redirects=False,
        timeout=15,
    )
    print(f"GET {path}: HTTP {response.status_code}")
    if not response.ok:
        raise SystemExit(response.text)
    return response.json()


print(signed_get("/v1/webhooks"))
user = signed_get(f"/v1/users/{quote(client_id)}")
main = next(account for account in user["data"]["accounts"] if account["name"] == "main")
print(f"Accounts: {json.dumps(user['data']['accounts'])}")
print(f"Set PWS_ACCOUNT_ID={main['id']}")
print(json.dumps(signed_get(f"/v1/accounts/{quote(main['id'])}/portfolio")))
```

```javascript JavaScript theme={null}
import crypto from 'node:crypto';

const baseUrl = new URL(process.env.PWS_BASE_URL);
const apiKey = process.env.PWS_API_KEY;
const secret = process.env.PWS_API_SECRET;
const clientId = process.env.PWS_CLIENT_ID?.replaceAll(' ', '');

if ([apiKey, secret, clientId].some(value => !value || /replace_with/i.test(value))) {
  throw new Error('Replace every configuration placeholder before running the example.');
}
if (baseUrl.hostname !== 'api.sandbox.services.payward.com') {
  throw new Error('This example runs only against the sandbox.');
}

// Nonces must increase for each API key. Use nanoseconds since the Unix epoch, and never repeat a value.
let lastNonce = 0n;
function nextNonce() {
  const now = BigInt(Date.now()) * 1_000_000n;
  lastNonce = now > lastNonce ? now : lastNonce + 1n;
  return lastNonce.toString();
}

function sign(path, nonce, body = '') {
  const digest = crypto
    .createHash('sha256')
    .update(nonce + body)
    .digest();
  const message = Buffer.concat([Buffer.from(path), digest]);
  return crypto.createHmac('sha512', Buffer.from(secret, 'base64')).update(message).digest('base64');
}

async function signedGet(path) {
  const nonce = nextNonce();
  const response = await fetch(`${baseUrl.origin}${path}`, {
    redirect: 'error',
    signal: AbortSignal.timeout(15_000),
    headers: { 'API-Key': apiKey, 'API-Nonce': nonce, 'API-Sign': sign(path, nonce) },
  });
  const text = await response.text();
  console.log(`GET ${path}: HTTP ${response.status}`);
  if (!response.ok) throw new Error(text);
  return JSON.parse(text);
}

console.log(await signedGet('/v1/webhooks'));
const user = await signedGet(`/v1/users/${encodeURIComponent(clientId)}`);
const main = user.data.accounts.find(account => account.name === 'main');
console.log(`Accounts: ${JSON.stringify(user.data.accounts)}`);
console.log(`Set PWS_ACCOUNT_ID=${main.id}`);
console.log(JSON.stringify(await signedGet(`/v1/accounts/${encodeURIComponent(main.id)}/portfolio`)));
```

Run it with Python 3.9 or later, with [`requests`](https://pypi.org/project/requests/) installed, or with Node.js 22 or later:

```bash Python theme={null}
python3 check_credentials.py
```

```bash JavaScript theme={null}
node check-credentials.mjs
```

| Response | What it means |
| - | - |
| `200` | List webhooks: your key and signing code work. Get user: the Client ID is right. Get portfolio: your account access works. A new account has an empty `assets` list. |
| `401` | Check the key and secret, that you sent all three signature headers, that the nonce is larger than the last one you used, and that you signed the exact path and query you requested. |
| `400` | Check that the account ID is the `main` account ID rather than the Client ID. |
| `403` | Your IP address may not be allowlisted, or the key may lack permission for this account. |
| `503` | The sandbox is temporarily unavailable. Retry after a few seconds. |

These error responses carry only `"code": "gateway"`, with no message saying which check failed.

## Receive webhooks on your machine

Payward delivers webhooks to a public HTTPS URL, so it can't reach `localhost` directly. To debug a webhook handler
locally:

1. Start your application locally, with your debugger attached.
2. Expose the local port through a tunneling service of your choice, such as ngrok. It gives you a public HTTPS URL that
   forwards to your machine.
3. Register that URL with [**Register webhook**](/api-reference/webhooks/register-webhook.md) (`POST /v1/webhooks`) in the sandbox, and subscribe to
   the events you're handling. The callback domain must be allowlisted first, so ask your Payward contact to allowlist
   your tunnel's domain. Store the [`signing_secret`](/api-reference/webhooks/register-webhook#response-data-signing-secret) from the response; you need it to verify deliveries.
4. Send a test event with [**Test webhook**](/api-reference/webhooks/test-webhook.md) (`POST /v1/webhooks/{webhook_id}/test`).
   The delivery reaches your handler, and your breakpoints fire. To exercise a real flow, trigger it, for example with a
   [simulated conversion deposit](/tabs/developer-documentation/payments/testing-conversions.md), which emits `conversion.*`
   events.
5. If a delivery doesn't arrive, check [**List webhook attempts**](/api-reference/webhooks/list-webhook-attempts.md)
   (`GET /v1/webhooks/{webhook_id}/attempts`) to see whether Payward tried to deliver it and what your endpoint returned.

Many tunneling tools issue a new URL each time they start. When yours changes, update or re-register the webhook. A
tunnel with a fixed domain avoids repeating the allowlisting.

Deliveries can be repeated, so deduplicate them by the `svix-id` header, as described in
[deposit webhook events](/tabs/developer-documentation/payments/deposits-and-withdrawals.md#deposit-webhook-events).

## Troubleshoot setup

| Symptom | Check |
| - | - |
| Connection fails or returns `403` | Confirm your current public IP address is allowlisted for the sandbox. |
| Authentication fails | Confirm the URL and credentials belong to the same environment, then check the nonce and signature path. |
| A method or capability is missing | Ask your Payward contact whether it's enabled for your sandbox account. |
| A webhook doesn't arrive | Check that the tunnel is running, the registered URL is current, and the flow emits the event. |
