# Self-Serve Staging: the Scriptable Path

> The full API sequence from a fresh email address to a first escrowed testnet transaction, designed for automation and AI agents.
> Source: https://developers.coinbax.com/docs/getting-started/self-serve-staging
> Last updated: 2026-09-09

Everything on this page is plain JSON over HTTPS, end to end. There is no
manual step: signup returns a verification code you redeem over the API, so a
human, a CI job, or an AI agent can go from a fresh email address to a first
testnet transaction without a mailbox or a browser. This sequence is validated
against staging.

Staging is isolated by construction: it only ever touches testnets, and the
sandbox credentials issued here carry a fixed, least-privilege scope set.
Production credentials are not self-serve.

## The sequence

### 1. Sign up

```bash
curl -X POST https://core-staging.coinbax.com/api/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dev@example.com",
    "password": "<strong password: 8+ chars, upper, lower, number, special>",
    "firstName": "Dev",
    "lastName": "Eloper",
    "companyName": "Example Co",
    "intendedUseCase": "escrow"
  }'
```

`email`, `password`, `firstName` and `lastName` are required. `companyName`
and `intendedUseCase` are optional here, because the browser flow collects
them after login, but sending them is recommended: `companyName` names the
workspace that is created for you, and without it the name falls back to
the local part of your email address. `intendedUseCase` is one of `marketplace`,
`freelance`, `escrow`, `b2b`, `saas`, `other`. On staging, accounts are
approved automatically and a workspace is created for you. The response
includes both the workspace id and, **on staging only**, a
`verificationCode` you use in the next step:

```json
{
  "success": true,
  "data": {
    "user": { "id": "...", "email": "dev@example.com" },
    "workspace": { "id": "...", "name": "Example Co", "status": "active" },
    "verificationCode": "a1b2c3..."
  }
}
```

```bash
export WORKSPACE_ID=...        # data.workspace.id
export VERIFICATION_CODE=...   # data.verificationCode (staging only)
```

Production never returns `verificationCode`; there, verification is the
emailed link. A verification email is always sent on staging too, so the
link still works if you prefer it.

If the email already has a Coinbax account, signup returns
`409 EMAIL_HAS_IDENTITY` with a `loginUrl` instead. Sign in rather than
signing up again; do not retry the signup.

Signup is rate-limited to **5 accounts per hour per IP address**, and 20 per
hour across a /24 (or a /48 on IPv6). Exceeding either returns the standard
`429` envelope with `Retry-After` and `X-RateLimit-*` headers. This is
generous for real automation, since a CI job or an agent needs one account
rather than many, and it exists because signup is an unauthenticated
write. If you need more accounts than that for a legitimate test matrix,
talk to us rather than working around the limit: reuse one account and
issue additional sandbox credentials instead.

### 2. Verify your email

Redeem the code from step 1. This runs the same verification the emailed link
would, and provisions your identity so you can sign in:

```bash
curl -X POST https://core-staging.coinbax.com/api/v1/auth/verify-code \
  -H "Content-Type: application/json" \
  -d "{ \"email\": \"dev@example.com\", \"code\": \"$VERIFICATION_CODE\" }"
```

A `200` means you are verified and can sign in. The code is single-use and
expires; if verification transiently fails, the code is preserved so you can
safely retry the same request.

### 3. Sign in

Sign in through the identity endpoint. It returns an access token (or an MFA
challenge, which self-serve accounts do not have enrolled by default).

```bash
curl -X POST https://core-staging.coinbax.com/api/v1/identity/login \
  -H "Content-Type: application/json" \
  -d '{ "email": "dev@example.com", "password": "..." }'
```

The response includes your access token; the workspace id was returned at
signup (step 1). Export both:

```bash
export WORKSPACE_JWT=...    # data.accessToken from the login response
export WORKSPACE_ID=...     # data.workspace.id from the signup response
```

This JWT authenticates you against the **Workspace API**
(`core-staging.coinbax.com`) — workspace administration, including issuing the
credentials in the next step. It is not the token you use to move money; that
one comes from step 5.

### 4. Issue sandbox credentials

```bash
curl -X POST "https://core-staging.coinbax.com/api/v1/workspaces/$WORKSPACE_ID/sandbox-credentials" \
  -H "Authorization: Bearer $WORKSPACE_JWT"
```

One call returns two credentials, each exactly once:

```json
{
  "success": true,
  "data": {
    "platformId": "...",
    "name": "sandbox-example-co",
    "apiKey": "cbx_...",
    "oauthClient": {
      "clientId": "coinbax_client_...",
      "clientSecret": "...",
      "scopes": ["read:transactions", "write:transactions", "..."]
    },
    "scopes": ["read:transactions", "write:transactions", "..."],
    "createdAt": "..."
  }
}
```

`apiKey` is always returned; `oauthClient` is conditional. Use `oauthClient`
for the Payments API v2, which accepts OAuth 2.0 Bearer tokens only. The
`apiKey` is for `/api/v1`, which is deprecated and sunsets on
**1 November 2026**.

Store what you were given immediately — neither the key nor the client secret
can be retrieved again. Read `oauthClient` defensively rather than assuming it
is present:

```bash
export COINBAX_CLIENT_ID=coinbax_client_...
export COINBAX_CLIENT_SECRET=...
```

Limits: 2 active sandbox credentials per workspace (revoke with
`DELETE .../sandbox-credentials/{platformId}`, then issue another), and
issuance is rate limited to 5 per hour.

If `oauthClient` is absent, the client could not be created and the `warning`
field says so. That credential works on `/api/v1` only — revoke it and issue
another rather than falling back to v1.

### 5. Exchange the client credentials for an access token

```bash
get_payments_token() {
  local response
  # The body goes in on stdin (-d @-), never as an argument: a secret in the
  # argument list is readable by any local process via `ps` or /proc.
  response=$(jq -n \
    --arg id "$COINBAX_CLIENT_ID" \
    --arg secret "$COINBAX_CLIENT_SECRET" \
    '{grant_type: "client_credentials", client_id: $id, client_secret: $secret}' \
    | curl -sS --fail-with-body -X POST \
        https://api-staging.coinbax.com/api/v2/oauth/token \
        -H "Content-Type: application/json" \
        -d @-) || { echo "token exchange failed" >&2; return 1; }

  # Only the parsed field is echoed. Never print the raw response on failure:
  # a successful body contains the bearer token, and terminal or CI logs keep it.
  printf '%s' "$response" | jq -er '.data.access_token' \
    || { echo "token exchange returned no access_token" >&2; return 1; }
}

PAYMENTS_TOKEN=$(get_payments_token) && export PAYMENTS_TOKEN
```

Three things this guards, all of which have bitten people:

- **The secret never reaches the argument list.** `jq -n` builds the JSON and
  `curl -d @-` reads it from stdin, so `ps` cannot see it.
- **A failed exchange fails loudly.** `--fail-with-body` stops curl swallowing
  an HTTP error, and `jq -e` exits non-zero on a missing or null field, so you
  never export an empty token and debug a confusing 401 later.
- **Nothing logs the response body.** On success it contains the bearer token.

This endpoint uses RFC 6749's snake_case field names, unlike the rest of the
API: `grant_type`, `client_id`, `client_secret`, and `access_token` in the
response. Tokens expire in an hour (`expires_in`, in seconds), so request a
fresh one rather than caching it indefinitely. An optional `scope` parameter
narrows a token below the client's granted set; it can never widen it.

### 6. Create your first transaction

A transaction moves funds between two wallet addresses. `fromAddress`,
`toAddress`, and `amount` are the only required fields, so you can create your
first payment immediately. `orchestrationType` `raw` is a direct transfer;
`coinbax` routes through programmable escrow and requires a `templateId` with
a deployed contract for your workspace.

```bash
curl -X POST https://api-staging.coinbax.com/api/v2/transactions \
  -H "Authorization: Bearer $PAYMENTS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fromAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb2",
    "toAddress":   "0x8ba1f109551bD432803012645Ac136ddd64DBA72",
    "amount": 100,
    "currency": "USDC",
    "blockchainNetwork": "base",
    "orchestrationType": "raw",
    "idempotencyKey": "your-unique-key"
  }'
```

The response returns the transaction in `pending`; poll
`GET /api/v2/transactions/{id}` (or subscribe a webhook) to watch it move
through the lifecycle.

Customer records are optional. They live on the Workspace API
(`/workspaces/{id}/customers`) and are managed by workspace admins, not
required to create a transaction. Pass `customerId` on a transaction only if
you have created one.

## What sandbox credentials can and cannot do

Both credentials carry the same fixed scope set, chosen server-side and never
taken from the request:

| | |
|---|---|
| transactions | `read`, `write`, `move`, `reverse`, `cancel`, `rescind` |
| customers | `read`, `write` |
| webhooks | `read`, `write` |
| templates | `read` |
| platform | `read` |

`move:transactions` and `reverse:transactions` are the v2 money-moving scopes:
`move:` covers the four release routes, `reverse:` covers refunds,
cancellations, rescissions, and batch cancel. v2 splits money movement out of
`write:transactions`, so a token holding only `write:` can create a
transaction but not release or reverse one. `cancel:` and `rescind:` are the
v1 equivalents, included so the API key still works against `/api/v1` until
it sunsets. They are not accepted on v2 and are no longer offered to new
OAuth clients; the sandbox is the exception because it issues both a v1 key
and a v2 client from one call.

Excluded by design: platform administration, credential minting, template
authoring, compliance triggers, and dispute operations.

Sandbox platforms also carry write quotas on staging: 200 transactions per
day, 100 customers, and 5 webhook subscriptions per sandbox platform. Hitting
a quota returns a standard `429` envelope with `Retry-After`.

## Notes for AI agents

- This site's [MCP server](/docs/resources/mcp) exposes this sequence via the
  `getting_started` tool, plus endpoint schemas via `get_endpoint`.
- Every request and response above follows the unified
  `success / data / meta / error` envelope.
- The whole flow is headless on staging: read `verificationCode` from the
  signup response and POST it to `/auth/verify-code`. No mailbox needed.
- On signup `409 EMAIL_HAS_IDENTITY`, switch to `/identity/login` with the
  same credentials; do not retry signup.
- On signup `429`, honour `Retry-After` and back off. Do not rotate IP
  addresses or `X-Forwarded-For` to evade the limit; the limit is not read
  from client-supplied headers, and the attempt will simply fail.
- Do not retry sandbox credential issuance on `409`; revoke an existing
  credential first (2 active max per workspace).
- Two distinct tokens are in play: `WORKSPACE_JWT` authenticates the Workspace
  API (`core-staging`), `PAYMENTS_TOKEN` authenticates the Payments API v2
  (`api-staging`). Sending the wrong one is a 401, not a 403.
- The token endpoint is the one place that uses snake_case
  (`grant_type`/`client_id`/`client_secret`, `access_token`). Everything else
  is camelCase.
- Credentials are staging-only. There is no API path from here to production
  credentials; that is a conversation with the Coinbax team.