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

# The wire protocol

> The HTTP contract underneath the SDK

The SDK collapses this page into `run()`. Read it when porting to a runtime the SDK does not cover, or to see exactly what the SDK sends on your behalf. Schemas for every request and response are in the **API reference** tab, which carries these seven operations and nothing else: everything else the platform serves belongs to the console, not to your app.

Four HTTP calls and one browser redirect: the token exchange, interact (blocking or streaming, one call on two routes), the approvals poll, and user-action. Consent is a navigation, never an API call. Two optional GETs sit beside them: `/connect/tool-definition` (unauthenticated, static) and `/connect/apps/me` (the app self-read).

Two base URLs appear below, and they are different hosts. The developer console shows both for your app; the SDK resolves both from your app key.

| Name                | What it is                                                                     |
| ------------------- | ------------------------------------------------------------------------------ |
| `PAYGENT_CLOUD_URL` | The API. Every server-to-server call. Default `https://api.paygent.payman.ai`. |
| `CONSOLE_URL`       | Your customer's Payman console, where they consent and approve.                |

| Header                      | Carries                                                                                          |
| --------------------------- | ------------------------------------------------------------------------------------------------ |
| `x-paygent-connect-app-key` | Your app key. Every call.                                                                        |
| `x-paygent-connect-grant`   | The [grant](/reference/keys) token. Every call after the exchange.                               |
| `x-paygent-environment`     | `sandbox` or `live`, on the interact and stream responses; the stream's only environment signal. |

Runtime calls need the first two together. Either one alone is a 401.

## The consent redirect

Build the link on your server and navigate top-level. No iframe, no popup: the customer is signing in to Payman. The SDK's popup consent depends on the SDK-served callback page; hand-rolled flows keep the plain navigation.

```js Node theme={null}
import { randomBytes } from "node:crypto";

// CSPRNG, stored against this signed-in user, single use, 10-minute TTL.
const state = randomBytes(32).toString("base64url");
await saveState(user.id, state, { ttlMs: 10 * 60 * 1000 });

// Hold the question they asked, so you can answer it when they return.
await savePendingQuestion(user.id, message);

const authorize = new URL("/connect/authorize", CONSOLE_URL);
authorize.searchParams.set("app_id", APP_ID);
authorize.searchParams.set("redirect_uri", REDIRECT_URI); // exactly as registered
authorize.searchParams.set("state", state);
```

`scopes` is optional and defaults to `interact`, the only scope.

On your callback, verify `state` first:

```js Node theme={null}
app.get("/callback", async (req, res) => {
  const owner = await consumeState(req.query.state); // atomic, single use
  if (!owner) return res.status(400).send("Rejected: unknown state");

  // access_denied. Nothing was issued. Offer the button again.
  if (req.query.error) return res.redirect(303, "/#latest");

  const grant = await exchangeCode(req.query.code);
  // ...store it, then replay the held question.
});
```

<Warning>
  `state` is your CSRF token and nobody checks it for you. Verify it before `code`, before `error`, before anything leaves your server. An unknown or reused state is somebody else's redirect: exchange nothing.
</Warning>

## `POST /connect/token`

The only credential call in the handshake. The browser is not part of it.

```js Node theme={null}
const response = await fetch(`${PAYGENT_CLOUD_URL}/connect/token`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-paygent-connect-app-key": APP_KEY,
  },
  body: JSON.stringify({
    code,
    redirectUri: REDIRECT_URI, // the same bytes the code was issued for
  }),
});

// 200
// {
//   "id": "…", "grantToken": "pgc_grant_…", "grantTokenMasked": "pgc_grant_••••ab01",
//   "appId": "…", "deploymentId": "…", "scopes": ["interact"],
//   "environment": "live", "createdAt": "…", "lastUsedAt": null, "expiresAt": "…"
// }
//
// 401 invalid_connect_credentials: the code was older than 60 seconds, already
// used, issued to a different app, or bound to a different redirectUri. One
// answer for all of them.
```

If the customer already had a live grant for this app, consenting again replaces it. You never hold two credentials for one connection.

<Warning>
  `grantToken` appears on this response only. Store it encrypted, server-side. Never retry the exchange: the code is single use and burns even on a failed attempt. Start the flow again instead.
</Warning>

## `POST /connect/interact`

```js Node theme={null}
const response = await fetch(`${PAYGENT_CLOUD_URL}/connect/interact`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-paygent-connect-app-key": APP_KEY,
    "x-paygent-connect-grant": grantToken,
  },
  body: JSON.stringify({
    userInput: "what's my balance?",
    sessionId: chatSessionId, // yours, stable per conversation
  }),
});

// 200
// { "status": "COMPLETED", "message": "…", "sessionId": "…",
//   "agentId": "…", "environment": "live", … }
//
// 200, frozen for the account owner's approval
// { "status": "APPROVAL_REQUIRED", "message": "",
//   "approvalRef": "pgtk_…", "approvalUrl": "…", "approvalExpiresAt": "…", … }
```

Render `message` labelled as the customer's own agent. `POST /connect/interact/stream` is the same call on a second route: that same customer-side agent's NDJSON relayed verbatim, one JSON object per line, with `x-paygent-environment` as the stream's only environment signal.

### Result names, wire and SDK

The served tool definition names four results; the SDK renames two of them.

| On the wire         | SDK `result.kind`  |
| ------------------- | ------------------ |
| `success`           | `ok`               |
| `needs_connection`  | `needs_connection` |
| `approval_required` | `approval`         |
| `error`             | `error`            |

`GET /connect/tool-definition` carries this mapping machine-readable as `wireMapping`.

## `GET /connect/approvals/{ref}`

Poll the ticket the interact response named.

```js Node theme={null}
const response = await fetch(`${PAYGENT_CLOUD_URL}/connect/approvals/${ref}`, {
  headers: {
    "x-paygent-connect-app-key": APP_KEY,
    "x-paygent-connect-grant": grantToken,
  },
});

// 200 while pending
// { "ref": "pgtk_…", "state": "pending", "expiresAt": "…", "approvalUrl": "…" }
//
// 200 once executed
// { "ref": "pgtk_…", "state": "consumed", "expiresAt": "…",
//   "executable": true, "executionStatus": "succeeded",
//   "providerTransactionId": "…" }
```

`approvalUrl` is present only while `state` is `pending`. Send the customer there: the owner approves on Payman's page with an emailed code, and there is no endpoint through which your app could approve or submit one. A 404 is a uniform refusal, never a verdict; keep polling until the clock runs out.

The wire reports five states. `consumed` means the gate spent the ticket, which happens before the bank is called, so the SDK derives two states from it:

| On the wire                                    | SDK approval state                             |
| ---------------------------------------------- | ---------------------------------------------- |
| `pending`                                      | `pending`                                      |
| `approved`                                     | `approved`                                     |
| `consumed`, no `executionStatus`               | `settling`                                     |
| `consumed` with `executionStatus: "succeeded"` | `posted`, receipt from `providerTransactionId` |
| `declined`                                     | `declined`                                     |
| `expired`                                      | `expired`                                      |
| anything unrecognised                          | `unknown`                                      |

## `POST /connect/interact/user-action/{userActionId}/{action}`

A run can stop to ask a question: which vendor, confirm this transfer. The question arrives as a frame on the stream carrying `userActionId`, a field key, and the options. Answer it here; the path accepts `submit`, `cancel`, `resend` and `expired`, and apps send the first three.

```js Node theme={null}
await fetch(
  `${PAYGENT_CLOUD_URL}/connect/interact/user-action/${userActionId}/submit`,
  {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-paygent-connect-app-key": APP_KEY,
      "x-paygent-connect-grant": grantToken,
    },
    body: JSON.stringify({ [fieldKey]: optionId }), // one of the offered options
  },
);
```

Only `submit` carries a body, and the body is always a chosen option id: never free text, never a code. `cancel` and `resend` post empty.

That is the whole protocol. After the consent return, answer the question the customer asked before the detour: the SDK stashes and replays it for you; hand-rolled, the stash and the replay are yours.
