# For AI agents

This page is written for coding agents building a Made Card partner integration. The machine-readable sources are:

- `/partners/openapi.json`: the full partner API as OpenAPI 3.1
- `/partners/llms.txt`: an index of these docs in llms.txt format
- `/partners/llms-full.txt`: every guide in one markdown file
- `/partners/docs/<page>.md`: any single guide as raw markdown

## The integration in ten lines

1. Server: `POST /v1/partners/auth` with `client_id` and `client_secret` (`pk_test_`/`sk_test_` in Test mode, `pk_live_`/`sk_live_` in Live mode; older keys without the prefix still work; `PRTN_0106` means the key belongs to the other mode's API base, named in `data.api_base`), cache `data.access_token` as the partner JWT, and send `Authorization: Bearer <partner JWT>` on every other call.
2. Server: `POST /v1/partners/link/sessions` with `kind: "apply"` and the customer's `prefill`, return `data.link_url` to the page.
3. Browser: load Made Link 1.2.0 (`/sdk/v1/made-link@1.2.0.js` with its integrity hash, see Web SDK), build `MadeLink.create({ linkUrl, onSuccess, onExit })` as soon as the page has `link_url`, and call `handler.open()` inside the click handler.
4. Browser: in `onSuccess(publicToken)`, send `publicToken` to your server. Nothing else. In `onExit`, handle every code (see Rules).
5. Server: `POST /v1/partners/link/token` with `public_token`, store `data.access_token` (`link_...`) and `data.user_id`.
6. Server: follow the application with webhooks (`application.status_changed` and `account.opened`, see Webhooks), or poll `application_status` from `GET /v1/partners/customers/{user_id}` as a fallback, until `account_id` is set.
7. Server: once `account_id` is set, read `/balance`, `/transactions`, `/payments`, and `/rewards` under `GET /v1/partners/customers/{user_id}`.
8. Server, later visits: `POST /v1/partners/sessions/launch` with that `access_token` and a `target_path`, then open `data.launch_url` in a tab the click already opened (see Launch URLs).
9. On `PRTN_0010`, start a `kind: "login"` session to reconnect that customer. To replace a stored `link_...` token, for example after a leak, call `POST /v1/partners/link/refresh`; the old token stops working at once.
10. On any `AUTH_001x`, get a new partner JWT and retry once.

## Rules

- Never send `client_secret`, the partner JWT, or a `link_...` token to a browser or mobile app.
- The customer `access_token` goes in the JSON body. It is never an `Authorization` header.
- Use `kind: "apply"` for new customers. Use `kind: "login"` only to reconnect. Returning customers get a launch URL, not a new session.
- Create the link session before the click and call `open()` synchronously in the click handler. Otherwise browsers block the window and `onExit` receives `POPUP_BLOCKED`.
- The page that opens Made Link must be on one of the partner's allowed origins (see Apply and link). Otherwise `onExit` receives `ORIGIN_NOT_ALLOWED` and `onSuccess` never fires.
- Handle every `onExit` code: `POPUP_BLOCKED`, `USER_CLOSED`, `ORIGIN_NOT_ALLOWED`, `IDENTITY_MISMATCH`, `SESSION_EXPIRED`, `ACCOUNT_UNAVAILABLE`, `REAUTH_REQUIRED`. `onExit(null)` means the customer left from inside the Made window.
  - `SESSION_EXPIRED` and `IDENTITY_MISMATCH`: create a new link session; the Made window won't try the old one again. For `IDENTITY_MISMATCH`, check the `prefill` you send, or lock fewer fields.
  - `USER_CLOSED` and `onExit(null)`: the customer left. Create a new link session for their next click, as for `SESSION_EXPIRED`; don't open the old `link_url` again. Made returns a session's `prefill` only for 15 minutes after the Made window first opens it.
  - `ACCOUNT_UNAVAILABLE` (Made Link 1.1.0, from `PRTN_0037`): Made won't connect this customer. Tell them to contact Made support. Don't retry, and don't create a new session for them.
  - `REAUTH_REQUIRED` (Made Link 1.2.0, from `PRTN_0063`): Made asked the customer to sign in again, and they left first. Let them retry with the same `link_url`.
- Pin Made Link in Live mode: `https://app.madecard.com/sdk/v1/made-link@1.2.0.js` with `integrity="sha384-YUtON3zsoNkJvhG73apj2mu4SzmgoxZkkOPvu7m4Oo4sY995f2x4M3PZoyHwsymn"` and `crossorigin="anonymous"`.
- To open a launch URL from a click that first calls your server, open a blank tab inside the click with `window.open("", "_blank")`, without `noopener`, then set that tab's location to `launch_url`. If the browser refuses the tab, show a link instead. Launch URLs has the code.
- For `kind: "apply"`, `onSuccess` fires once the customer has a Made login. The Made window stays open while they finish; check the application with `GET /v1/partners/customers/{user_id}`.
- A launch URL opens `target_path` only once `account_id` is set. Before that it opens the customer's application.
- A working reference integration, Northstar Home Loans (demo), runs in Test mode at https://madepartner.com. Anyone can open it, with no sign-in. Its developer page shows the code behind each step, but build from these docs and `/partners/openapi.json`: the site is a demo, not a specification.
- `public_token` and `launch_url` are single use. Do not store or reuse them.
- Branch on the `error` code, not the message.
- On `PRTN_0032` from `/balance`, show the balance as temporarily unavailable, never as $0, keep showing the other reads, and retry later.
- `/balance` allows 20 calls a minute for each customer, and values can be up to 30 seconds old (`as_of` says when). On `PRTN_0036`, wait the `Retry-After` seconds before the next balance call. Read balances when a page shows them, not on a timer.
- Follow an application with webhooks, or with `application_status` from `GET /v1/partners/customers/{user_id}` as a fallback. Amounts are JSON numbers, and a transaction `id` is an integer.
- A webhook only says "look again". Verify `Made-Signature` over the raw request body before parsing it (HMAC-SHA256 keyed with the whole `whsec_` secret, compared in constant time; Webhooks has a test vector), deduplicate on the event `id`, answer 2xx within 10 seconds, then re-read the customer through the API. Events can arrive more than once and in any order.
- The webhook signing secret is a server secret, like `client_secret`: never send it to a browser or app, and never log it.
- Nothing in this API moves money or changes an account. Do not look for a payment endpoint.

## Prompt you can paste

```text
Integrate my app with the Made Card Partners API.
Spec: https://api-sandbox.madecard.com/partners/openapi.json
Guide: https://api-sandbox.madecard.com/partners/llms-full.txt
Follow the "For AI agents" rules exactly. Keep client_secret, the partner JWT,
and every link_ token on the server. Build: a server route that creates a link
session, a page that loads Made Link 1.2.0 (pinned, with its integrity hash) and
opens it from a button, a server route that exchanges the public_token and stores
the access_token and user_id, and a route that returns a launch URL.
Handle every onExit code, including ACCOUNT_UNAVAILABLE.
Add a webhook route that verifies Made-Signature on the raw body, dedupes on
the event id, answers 204 fast, then re-reads the customer through the API.
Use environment variables MADE_API_BASE, MADE_CLIENT_ID, MADE_CLIENT_SECRET,
MADE_WEBHOOK_SECRET.
```

## Minimal Node server

```js
import express from "express";

const API = process.env.MADE_API_BASE; // https://api-sandbox.madecard.com/v1
const app = express();
app.use(express.json());

let jwt = null;
let jwtExpiresAt = 0;

async function partnerJwt() {
  if (jwt && Date.now() < jwtExpiresAt - 60_000) return jwt;
  const res = await fetch(`${API}/partners/auth`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ client_id: process.env.MADE_CLIENT_ID, client_secret: process.env.MADE_CLIENT_SECRET }),
  });
  const { data } = await res.json();
  jwt = data.access_token;
  jwtExpiresAt = Date.now() + data.expires_in * 1000;
  return jwt;
}

async function made(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${await partnerJwt()}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const payload = await res.json();
  if (!res.ok) throw Object.assign(new Error(payload.message), { code: payload.error, status: res.status });
  return payload.data;
}

app.post("/made/link-session", async (req, res) => {
  const session = await made("/partners/link/sessions", { kind: "apply", prefill: req.body.prefill ?? {} });
  res.json({ link_url: session.link_url });
});

app.post("/made/exchange", async (req, res) => {
  const link = await made("/partners/link/token", { public_token: req.body.public_token });
  // Save link.access_token and link.user_id with your customer record here.
  res.json({ connected: true });
});

app.post("/made/launch", async (req, res) => {
  const accessToken = "link_..."; // Load the stored token for the signed-in customer.
  const launch = await made("/partners/sessions/launch", { access_token: accessToken, target_path: "/dashboard/home" });
  res.json({ launch_url: launch.launch_url });
});

app.listen(3000);
```
