From 7cd7301488686cc93ae7ba844f8289f3fe9b0cfe Mon Sep 17 00:00:00 2001 From: Dhruv Pareek Date: Wed, 12 Aug 2026 14:12:38 -0700 Subject: [PATCH] feat(grid-api): move card spending limits onto cards --- cli/README.md | 8 +- cli/src/commands/auth.ts | 30 +------ cli/src/commands/cards.ts | 82 +++++++++++++++++-- cli/test/auth.test.ts | 28 +------ cli/test/cards.test.ts | 78 ++++++++++++++++++ mintlify/openapi.yaml | 75 +++++++++-------- openapi.yaml | 75 +++++++++-------- .../components/schemas/auth/DelegatedKey.yaml | 8 -- .../auth/DelegatedKeyCreateRequest.yaml | 12 --- .../auth/DelegatedKeySpendingLimit.yaml | 22 ----- openapi/components/schemas/cards/Card.yaml | 13 +++ .../schemas/cards/CardCreateRequest.yaml | 13 ++- .../schemas/cards/CardUpdateRequest.yaml | 24 ++++-- openapi/paths/cards/cards.yaml | 8 ++ openapi/paths/cards/cards_{id}.yaml | 19 ++++- .../webhooks/card-funding-source-change.yaml | 1 + openapi/webhooks/card-state-change.yaml | 3 + 17 files changed, 318 insertions(+), 181 deletions(-) delete mode 100644 openapi/components/schemas/auth/DelegatedKeySpendingLimit.yaml diff --git a/cli/README.md b/cli/README.md index 03ca38dea..ce98bd912 100644 --- a/cli/README.md +++ b/cli/README.md @@ -318,13 +318,16 @@ grid cards get # Issue a virtual card grid cards create \ --cardholder-id \ - --funding-sources "InternalAccount:1,InternalAccount:2" + --funding-sources "InternalAccount:1,InternalAccount:2" \ + --max-spend-per-transaction 5000 -# Freeze / unfreeze / close, or replace funding sources +# Freeze / unfreeze / close, replace funding sources, or change the spending limit grid cards update --state FROZEN grid cards update --state ACTIVE grid cards update --state CLOSED grid cards update --funding-sources "InternalAccount:3" +grid cards update --max-spend-per-transaction 10000 +grid cards update --clear-max-spend-per-transaction # Reveal card details — prints a short-lived panEmbedUrl to render in an iframe. # Do not store or log it. @@ -355,7 +358,6 @@ grid auth delegated-keys list --account-id grid auth delegated-keys get grid auth delegated-keys create \ --card-id --internal-account-id --nickname "Card key" \ - --spending-limit USD:5000 --spending-limit EUR:4000 \ --wallet-signature --request-id grid auth delegated-keys revoke # no signature needed diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index b53780b9e..25658607e 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -1,4 +1,4 @@ -import { Command, InvalidArgumentError } from "commander"; +import { Command } from "commander"; import { GridClient } from "../client"; import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; @@ -36,28 +36,6 @@ interface AuthSession { expiresAt: string; } -// Collects a repeated "CURRENCY:amount" flag into spending-limit objects. -// Amounts are integers in the smallest currency unit; a malformed or non-integer -// value is rejected up front rather than silently sent as null. -function collectSpendingLimit( - value: string, - previous: Array<{ currencyCode: string; maxPerTransaction: number }> = [] -): Array<{ currencyCode: string; maxPerTransaction: number }> { - const match = /^([A-Z0-9]{3,16}):(\d+)$/.exec(value); - if (!match) { - throw new InvalidArgumentError( - `expected CURRENCY:amount with an uppercase code and an integer amount, e.g. USD:5000 (got "${value}")` - ); - } - const maxPerTransaction = Number(match[2]); - if (!Number.isSafeInteger(maxPerTransaction) || maxPerTransaction < 1) { - throw new InvalidArgumentError( - `spending limit amount must be a positive integer within the safe range (got "${value}")` - ); - } - return [...previous, { currencyCode: match[1], maxPerTransaction }]; -} - export function registerAuthCommand( program: Command, getClient: (opts: GlobalOptions) => GridClient | null @@ -269,11 +247,6 @@ export function registerAuthCommand( .requiredOption("--card-id ", "Card ID") .requiredOption("--internal-account-id ", "Embedded Wallet internal account ID") .requiredOption("--nickname ", "Human-readable label for the key") - .option( - "--spending-limit ", - "Per-transaction limit, e.g. USD:5000 (repeatable)", - collectSpendingLimit - ) ).action(async (options) => { const opts = program.opts(); const client = getClient(opts); @@ -285,7 +258,6 @@ export function registerAuthCommand( internalAccountId: options.internalAccountId, nickname: options.nickname, }; - if (options.spendingLimit) body.spendingLimits = options.spendingLimit; const response = await client.post( "/auth/delegated-keys", diff --git a/cli/src/commands/cards.ts b/cli/src/commands/cards.ts index 8c5903988..992b4774e 100644 --- a/cli/src/commands/cards.ts +++ b/cli/src/commands/cards.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import { GridClient, PaginatedResponse } from "../client"; import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; @@ -13,6 +13,7 @@ interface Card { form: "VIRTUAL"; last4?: string; fundingSources: string[]; + maxSpendPerTransaction: number | null; currency?: string; createdAt: string; updatedAt: string; @@ -23,6 +24,21 @@ interface CardRevealResponse { expiresAt: string; } +function parseMaxSpendPerTransaction(value: string): number { + if (!/^\d+$/.test(value)) { + throw new InvalidArgumentError( + `--max-spend-per-transaction must be a positive integer (got "${value}")` + ); + } + const amount = Number(value); + if (!Number.isSafeInteger(amount) || amount < 1) { + throw new InvalidArgumentError( + `--max-spend-per-transaction must be a positive integer within the safe range (got "${value}")` + ); + } + return amount; +} + export function registerCardsCommand( program: Command, getClient: (opts: GlobalOptions) => GridClient | null @@ -84,6 +100,11 @@ export function registerCardsCommand( .requiredOption("--funding-sources ", "Comma-separated internal account IDs, in priority order") .option("--form
", "Card form (VIRTUAL)", "VIRTUAL") .option("--platform-card-id ", "Your platform's identifier for the card") + .option( + "--max-spend-per-transaction ", + "Maximum amount per transaction in the card currency's smallest unit", + parseMaxSpendPerTransaction + ) .action(async (options) => { const opts = program.opts(); const client = getClient(opts); @@ -102,6 +123,9 @@ export function registerCardsCommand( fundingSources, }; if (options.platformCardId) body.platformCardId = options.platformCardId; + if (options.maxSpendPerTransaction !== undefined) { + body.maxSpendPerTransaction = options.maxSpendPerTransaction; + } const response = await client.post("/cards", body); outputResponse(response); @@ -110,9 +134,20 @@ export function registerCardsCommand( addSignedOptions( cardsCmd .command("update ") - .description("Update a card (freeze/unfreeze, replace funding sources, or close)") + .description( + "Update a card (freeze/unfreeze, replace funding sources, set a spending limit, or close)" + ) .option("--state ", "Target state: ACTIVE, FROZEN, or CLOSED") .option("--funding-sources ", "Comma-separated internal account IDs (fully replaces the binding)") + .option( + "--max-spend-per-transaction ", + "Set the maximum amount per transaction in the card currency's smallest unit", + parseMaxSpendPerTransaction + ) + .option( + "--clear-max-spend-per-transaction", + "Remove the per-transaction spending limit" + ) ).action(async (cardId: string, options) => { const opts = program.opts(); const client = getClient(opts); @@ -126,8 +161,17 @@ export function registerCardsCommand( } const fundingSources = parseList(options.fundingSources); - if (!options.state && options.fundingSources === undefined) { - output(formatError("Provide --state and/or --funding-sources")); + if ( + !options.state && + options.fundingSources === undefined && + options.maxSpendPerTransaction === undefined && + !options.clearMaxSpendPerTransaction + ) { + output( + formatError( + "Provide --state, --funding-sources, --max-spend-per-transaction, and/or --clear-max-spend-per-transaction" + ) + ); process.exitCode = 1; return; } @@ -138,8 +182,29 @@ export function registerCardsCommand( process.exitCode = 1; return; } - if (options.state === "CLOSED" && options.fundingSources !== undefined) { - output(formatError("--state CLOSED cannot be combined with --funding-sources")); + if ( + options.maxSpendPerTransaction !== undefined && + options.clearMaxSpendPerTransaction + ) { + output( + formatError( + "--max-spend-per-transaction cannot be combined with --clear-max-spend-per-transaction" + ) + ); + process.exitCode = 1; + return; + } + if ( + options.state === "CLOSED" && + (options.fundingSources !== undefined || + options.maxSpendPerTransaction !== undefined || + options.clearMaxSpendPerTransaction) + ) { + output( + formatError( + "--state CLOSED cannot be combined with funding-source or spending-limit changes" + ) + ); process.exitCode = 1; return; } @@ -147,6 +212,11 @@ export function registerCardsCommand( const body: Record = {}; if (options.state) body.state = options.state; if (fundingSources) body.fundingSources = fundingSources; + if (options.maxSpendPerTransaction !== undefined) { + body.maxSpendPerTransaction = options.maxSpendPerTransaction; + } else if (options.clearMaxSpendPerTransaction) { + body.maxSpendPerTransaction = null; + } const response = await client.patch( `/cards/${cardId}`, diff --git a/cli/test/auth.test.ts b/cli/test/auth.test.ts index 497c8f023..d2ff625af 100644 --- a/cli/test/auth.test.ts +++ b/cli/test/auth.test.ts @@ -118,7 +118,7 @@ describe("auth delegated-keys", () => { expect(calls).toBe(0); }); - it("create builds the body with parsed spending limits", async () => { + it("create builds the delegated-key request body", async () => { const { request } = await runCli([ "auth", "delegated-keys", @@ -129,19 +129,11 @@ describe("auth delegated-keys", () => { "InternalAccount:1", "--nickname", "Card key", - "--spending-limit", - "USD:5000", - "--spending-limit", - "EUR:4000", ]); expect(request?.body).toMatchObject({ cardId: "Card:1", internalAccountId: "InternalAccount:1", nickname: "Card key", - spendingLimits: [ - { currencyCode: "USD", maxPerTransaction: 5000 }, - { currencyCode: "EUR", maxPerTransaction: 4000 }, - ], }); }); @@ -156,24 +148,6 @@ describe("auth delegated-keys", () => { expect(request?.method).toBe("DELETE"); expect(request?.path).toBe("/grid/v1/auth/delegated-keys/DelegatedKey:1"); }); - - it("rejects a malformed spending limit before sending", async () => { - await expect( - runCli([ - "auth", - "delegated-keys", - "create", - "--card-id", - "Card:1", - "--internal-account-id", - "InternalAccount:1", - "--nickname", - "Card key", - "--spending-limit", - "USD5000", - ]) - ).rejects.toThrow(); - }); }); describe("auth sessions", () => { diff --git a/cli/test/cards.test.ts b/cli/test/cards.test.ts index f34265e26..aa20c00e6 100644 --- a/cli/test/cards.test.ts +++ b/cli/test/cards.test.ts @@ -39,6 +39,36 @@ describe("cards create", () => { fundingSources: ["InternalAccount:1", "InternalAccount:2"], }); }); + + it("sets a per-transaction spending limit", async () => { + const { request } = await runCli([ + "cards", + "create", + "--cardholder-id", + "Customer:abc", + "--funding-sources", + "InternalAccount:1", + "--max-spend-per-transaction", + "5000", + ]); + + expect(request?.body).toMatchObject({ maxSpendPerTransaction: 5000 }); + }); + + it("rejects a non-positive spending limit", async () => { + await expect( + runCli([ + "cards", + "create", + "--cardholder-id", + "Customer:abc", + "--funding-sources", + "InternalAccount:1", + "--max-spend-per-transaction", + "0", + ]) + ).rejects.toThrow(); + }); }); describe("cards update", () => { @@ -73,6 +103,29 @@ describe("cards update", () => { expect(request?.headers["Request-Id"]).toBe("req-1"); }); + it("sets a per-transaction spending limit", async () => { + const { request } = await runCli([ + "cards", + "update", + "Card:1", + "--max-spend-per-transaction", + "7500", + ]); + + expect(request?.body).toEqual({ maxSpendPerTransaction: 7500 }); + }); + + it("clears a per-transaction spending limit", async () => { + const { request } = await runCli([ + "cards", + "update", + "Card:1", + "--clear-max-spend-per-transaction", + ]); + + expect(request?.body).toEqual({ maxSpendPerTransaction: null }); + }); + it("rejects an update with no state or funding sources", async () => { const { calls } = await runCli(["cards", "update", "Card:1"]); expect(calls).toBe(0); @@ -125,6 +178,31 @@ describe("cards update", () => { ]); expect(calls).toBe(0); }); + + it("rejects setting and clearing the spending limit together", async () => { + const { calls } = await runCli([ + "cards", + "update", + "Card:1", + "--max-spend-per-transaction", + "5000", + "--clear-max-spend-per-transaction", + ]); + expect(calls).toBe(0); + }); + + it("rejects CLOSED combined with a spending-limit change", async () => { + const { calls } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "CLOSED", + "--max-spend-per-transaction", + "5000", + ]); + expect(calls).toBe(0); + }); }); describe("cards reveal", () => { diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index c276df090..3425f27b7 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -8390,6 +8390,8 @@ paths: description: | Issue a new card for a cardholder. Every card must be bound to at least one funding source at create time. The cardholder must have KYC status `APPROVED` before a card can be issued; otherwise the request is rejected with `CARDHOLDER_KYC_NOT_APPROVED`. + An optional `maxSpendPerTransaction` value sets the largest amount a single card transaction may authorize. The limit is enforced by Grid for card programs where Grid makes the authorization decision, whether the card is funded by an Embedded Wallet account or custodial fiat. Omit it for no limit. The value is in the smallest unit of the card's currency. + If any funding source is an Embedded Wallet internal account, the cardholder must authorize Grid to sign Spark token transactions for that card funding source by completing the delegated-key creation flow with `POST /auth/delegated-keys`. Until an active delegated key exists for that funding source, Authorization Decisioning cannot use it to fund card transactions. New cards start in `state: "PROCESSING"` while the card issuer provisions the card. The `card.state_change` webhook fires on each state transition, including the transition to `ACTIVE` (or to `CLOSED` with `stateReason: "ISSUER_REJECTED"` if provisioning fails). @@ -8413,6 +8415,7 @@ paths: form: VIRTUAL fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 responses: '201': description: Card created successfully. Newly-created cards start in `PROCESSING` while the issuer provisions them. Cards funded by an Embedded Wallet internal account also require an active delegated key for that funding source before Authorization Decisioning can use it. @@ -8584,10 +8587,11 @@ paths: patch: summary: Update a card description: | - Update a card's `state` and / or its bound `fundingSources`. At least one of the two fields must be supplied. + Update a card's `state`, bound `fundingSources`, and / or `maxSpendPerTransaction`. At least one field must be supplied. - `state` transitions are limited to `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`. `CLOSED` is terminal and irreversible. Any other transition returns `409 INVALID_STATE_TRANSITION`. - `fundingSources`, when supplied, fully replaces the card's bound funding sources. Array order determines the priority Authorization Decisioning tries them in. Each id must belong to the cardholder and be denominated in the card's currency; the list must contain at least one source. `fundingSources` cannot be supplied alongside `state: CLOSED`. + - `maxSpendPerTransaction`, when supplied, replaces the card's application-enforced per-transaction limit. Supply a positive integer in the smallest unit of the card's currency to set it or null to clear it. Limits are supported only for card programs where Grid makes the authorization decision. `maxSpendPerTransaction` cannot be supplied alongside `state: CLOSED`. This endpoint is authenticated by the platform credential alone and returns `200` directly. It deliberately does not use Grid's 202 → signed-retry pattern: that pattern signs with the session key of a credential on the owning internal account, so it models actions taken *by* the end user on their own credentials or funds. Freezing or closing a card is routinely an action taken *about* a user and without them present - fraud response, offboarding, an ops-driven freeze - and requiring the cardholder's signature would make exactly those cases impossible. Operations that expose sensitive card data (`POST /cards/{id}/reveal`, 3DS password retrieval) are SCA-railed instead, because there the cardholder is the party being served. @@ -8624,6 +8628,14 @@ paths: fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + setSpendingLimit: + summary: Set the card's per-transaction spending limit + value: + maxSpendPerTransaction: 10000 + clearSpendingLimit: + summary: Remove the card's per-transaction spending limit + value: + maxSpendPerTransaction: null freezeAndUpdateSources: summary: Freeze the card and replace its funding sources in one call value: @@ -11250,6 +11262,7 @@ webhooks: expYear: 2029 fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 currency: USD processorRef: card_b81c2a4f issuerRef: lead_card_7a1b9c3d @@ -11269,6 +11282,7 @@ webhooks: form: VIRTUAL fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: null currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-08T14:12:00Z' @@ -11290,6 +11304,7 @@ webhooks: expYear: 2029 fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-09T09:00:00Z' @@ -11364,6 +11379,7 @@ webhooks: fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: null currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-08T14:30:00Z' @@ -23578,24 +23594,6 @@ components: - `ACTIVE`: The policy is granted and the key may stamp quote executions. - `REVOKED`: The delegated user has been deleted and the key can no longer sign. example: ACTIVE - DelegatedKeySpendingLimit: - title: Delegated Key Spending Limit - type: object - required: - - currencyCode - - maxPerTransaction - properties: - currencyCode: - type: string - pattern: ^[A-Z0-9]{3,16}$ - description: Uppercase alphanumeric currency code the limit applies to — ISO 4217 for fiat (e.g. USD), or a Grid token code for stablecoins (e.g. USDB). Must match the card's currency; requests with any other currency are rejected. - example: USD - maxPerTransaction: - type: integer - format: int64 - minimum: 1 - description: Largest amount a single card transaction may authorize, in the smallest unit of the currency (e.g., cents for USD). - example: 5000 DelegatedKey: title: Delegated Key type: object @@ -23637,12 +23635,6 @@ components: example: Settlement service key status: $ref: '#/components/schemas/DelegatedKeyStatus' - spendingLimits: - type: array - uniqueItems: true - description: Per-transaction spending limits the key was created with, at most one entry per currency. Absent when the key has no limits. - items: - $ref: '#/components/schemas/DelegatedKeySpendingLimit' createdAt: type: string format: date-time @@ -23686,12 +23678,6 @@ components: maxLength: 256 description: Human-readable label for the delegated key. example: Card payments key - spendingLimits: - type: array - uniqueItems: true - description: Optional per-transaction spending limits for the key, at most one entry per currency — a request with duplicate currency entries is rejected. Grid enforces the limits when authorizing card transactions funded by the key's Embedded Wallet account; a currency with no entry is unlimited. Immutable — revoke the key and create a new one to change limits. - items: - $ref: '#/components/schemas/DelegatedKeySpendingLimit' DelegatedKeySignedRequestChallenge: title: Delegated Key Signed Request Challenge description: 202 response returned from the delegated-key endpoints. Stamp `payloadToSign` with the session API keypair of a verified credential on the delegated key's Embedded Wallet funding account, then retry the same request with the full stamp in `Grid-Wallet-Signature` and the `requestId` echoed in `Request-Id`. @@ -24255,6 +24241,7 @@ components: - state - form - fundingSources + - maxSpendPerTransaction - createdAt - updatedAt properties: @@ -24304,6 +24291,14 @@ components: example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: + anyOf: + - type: integer + format: int64 + minimum: 1 + - type: 'null' + description: Largest amount a single card transaction may authorize, in the smallest unit of the card's `currency`. Null means the card has no application-enforced per-transaction limit. A transaction for exactly this amount is allowed. + example: 5000 currency: type: string description: Currency the card transacts in (ISO 4217 for fiat, tickers for crypto). Derived from the funding sources at issue time — all funding sources bound to a card must be denominated in the same card-eligible currency. @@ -24374,15 +24369,21 @@ components: $ref: '#/components/schemas/CardForm' fundingSources: type: array - description: Internal account ids to bind as funding sources, in priority order. The first entry is tried first by Authorization Decisioning. Every card must be bound to at least one source, and every source must belong to the cardholder and be denominated in a card-eligible currency (USDB in v1); otherwise the request is rejected with `FUNDING_SOURCE_INELIGIBLE`. + description: Internal account ids to bind as funding sources, in priority order. The first entry is tried first by Authorization Decisioning. Every card must be bound to at least one source, and every source must belong to the cardholder and be denominated in a card-eligible currency; otherwise the request is rejected with `FUNDING_SOURCE_INELIGIBLE`. minItems: 1 items: type: string example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: + type: integer + format: int64 + minimum: 1 + description: Optional largest amount a single card transaction may authorize, in the smallest unit of the card currency derived from its funding sources. Omit this field for no limit. Supported only for card programs whose authorization decisions are made by Grid. A transaction for exactly this amount is allowed. + example: 5000 CardUpdateRequest: type: object - description: Update request for `PATCH /cards/{id}`. At least one of `state` or `fundingSources` must be supplied. `state` transitions are limited to `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`; any other transition returns `409 INVALID_STATE_TRANSITION`. `CLOSED` is terminal and irreversible and cannot be combined with `fundingSources`. `fundingSources`, when supplied, fully replaces the card's bound funding sources — the array order determines the priority Authorization Decisioning tries them in. + description: Update request for `PATCH /cards/{id}`. At least one of `state`, `fundingSources`, or `maxSpendPerTransaction` must be supplied. `state` transitions are limited to `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`; any other transition returns `409 INVALID_STATE_TRANSITION`. `CLOSED` is terminal and irreversible and cannot be combined with `fundingSources` or `maxSpendPerTransaction`. `fundingSources`, when supplied, fully replaces the card's bound funding sources — the array order determines the priority Authorization Decisioning tries them in. properties: state: type: string @@ -24401,6 +24402,14 @@ components: example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: + anyOf: + - type: integer + format: int64 + minimum: 1 + - type: 'null' + description: 'Replacement per-transaction spending limit for the card, in the smallest unit of its currency. Omit this field to leave the current limit unchanged, supply null to clear it, or supply a positive integer to set it. Supported only for card programs whose authorization decisions are made by Grid. Cannot be supplied alongside `state: CLOSED`.' + example: 10000 CardRevealResponse: type: object required: diff --git a/openapi.yaml b/openapi.yaml index c276df090..3425f27b7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8390,6 +8390,8 @@ paths: description: | Issue a new card for a cardholder. Every card must be bound to at least one funding source at create time. The cardholder must have KYC status `APPROVED` before a card can be issued; otherwise the request is rejected with `CARDHOLDER_KYC_NOT_APPROVED`. + An optional `maxSpendPerTransaction` value sets the largest amount a single card transaction may authorize. The limit is enforced by Grid for card programs where Grid makes the authorization decision, whether the card is funded by an Embedded Wallet account or custodial fiat. Omit it for no limit. The value is in the smallest unit of the card's currency. + If any funding source is an Embedded Wallet internal account, the cardholder must authorize Grid to sign Spark token transactions for that card funding source by completing the delegated-key creation flow with `POST /auth/delegated-keys`. Until an active delegated key exists for that funding source, Authorization Decisioning cannot use it to fund card transactions. New cards start in `state: "PROCESSING"` while the card issuer provisions the card. The `card.state_change` webhook fires on each state transition, including the transition to `ACTIVE` (or to `CLOSED` with `stateReason: "ISSUER_REJECTED"` if provisioning fails). @@ -8413,6 +8415,7 @@ paths: form: VIRTUAL fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 responses: '201': description: Card created successfully. Newly-created cards start in `PROCESSING` while the issuer provisions them. Cards funded by an Embedded Wallet internal account also require an active delegated key for that funding source before Authorization Decisioning can use it. @@ -8584,10 +8587,11 @@ paths: patch: summary: Update a card description: | - Update a card's `state` and / or its bound `fundingSources`. At least one of the two fields must be supplied. + Update a card's `state`, bound `fundingSources`, and / or `maxSpendPerTransaction`. At least one field must be supplied. - `state` transitions are limited to `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`. `CLOSED` is terminal and irreversible. Any other transition returns `409 INVALID_STATE_TRANSITION`. - `fundingSources`, when supplied, fully replaces the card's bound funding sources. Array order determines the priority Authorization Decisioning tries them in. Each id must belong to the cardholder and be denominated in the card's currency; the list must contain at least one source. `fundingSources` cannot be supplied alongside `state: CLOSED`. + - `maxSpendPerTransaction`, when supplied, replaces the card's application-enforced per-transaction limit. Supply a positive integer in the smallest unit of the card's currency to set it or null to clear it. Limits are supported only for card programs where Grid makes the authorization decision. `maxSpendPerTransaction` cannot be supplied alongside `state: CLOSED`. This endpoint is authenticated by the platform credential alone and returns `200` directly. It deliberately does not use Grid's 202 → signed-retry pattern: that pattern signs with the session key of a credential on the owning internal account, so it models actions taken *by* the end user on their own credentials or funds. Freezing or closing a card is routinely an action taken *about* a user and without them present - fraud response, offboarding, an ops-driven freeze - and requiring the cardholder's signature would make exactly those cases impossible. Operations that expose sensitive card data (`POST /cards/{id}/reveal`, 3DS password retrieval) are SCA-railed instead, because there the cardholder is the party being served. @@ -8624,6 +8628,14 @@ paths: fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + setSpendingLimit: + summary: Set the card's per-transaction spending limit + value: + maxSpendPerTransaction: 10000 + clearSpendingLimit: + summary: Remove the card's per-transaction spending limit + value: + maxSpendPerTransaction: null freezeAndUpdateSources: summary: Freeze the card and replace its funding sources in one call value: @@ -11250,6 +11262,7 @@ webhooks: expYear: 2029 fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 currency: USD processorRef: card_b81c2a4f issuerRef: lead_card_7a1b9c3d @@ -11269,6 +11282,7 @@ webhooks: form: VIRTUAL fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: null currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-08T14:12:00Z' @@ -11290,6 +11304,7 @@ webhooks: expYear: 2029 fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-09T09:00:00Z' @@ -11364,6 +11379,7 @@ webhooks: fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: null currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-08T14:30:00Z' @@ -23578,24 +23594,6 @@ components: - `ACTIVE`: The policy is granted and the key may stamp quote executions. - `REVOKED`: The delegated user has been deleted and the key can no longer sign. example: ACTIVE - DelegatedKeySpendingLimit: - title: Delegated Key Spending Limit - type: object - required: - - currencyCode - - maxPerTransaction - properties: - currencyCode: - type: string - pattern: ^[A-Z0-9]{3,16}$ - description: Uppercase alphanumeric currency code the limit applies to — ISO 4217 for fiat (e.g. USD), or a Grid token code for stablecoins (e.g. USDB). Must match the card's currency; requests with any other currency are rejected. - example: USD - maxPerTransaction: - type: integer - format: int64 - minimum: 1 - description: Largest amount a single card transaction may authorize, in the smallest unit of the currency (e.g., cents for USD). - example: 5000 DelegatedKey: title: Delegated Key type: object @@ -23637,12 +23635,6 @@ components: example: Settlement service key status: $ref: '#/components/schemas/DelegatedKeyStatus' - spendingLimits: - type: array - uniqueItems: true - description: Per-transaction spending limits the key was created with, at most one entry per currency. Absent when the key has no limits. - items: - $ref: '#/components/schemas/DelegatedKeySpendingLimit' createdAt: type: string format: date-time @@ -23686,12 +23678,6 @@ components: maxLength: 256 description: Human-readable label for the delegated key. example: Card payments key - spendingLimits: - type: array - uniqueItems: true - description: Optional per-transaction spending limits for the key, at most one entry per currency — a request with duplicate currency entries is rejected. Grid enforces the limits when authorizing card transactions funded by the key's Embedded Wallet account; a currency with no entry is unlimited. Immutable — revoke the key and create a new one to change limits. - items: - $ref: '#/components/schemas/DelegatedKeySpendingLimit' DelegatedKeySignedRequestChallenge: title: Delegated Key Signed Request Challenge description: 202 response returned from the delegated-key endpoints. Stamp `payloadToSign` with the session API keypair of a verified credential on the delegated key's Embedded Wallet funding account, then retry the same request with the full stamp in `Grid-Wallet-Signature` and the `requestId` echoed in `Request-Id`. @@ -24255,6 +24241,7 @@ components: - state - form - fundingSources + - maxSpendPerTransaction - createdAt - updatedAt properties: @@ -24304,6 +24291,14 @@ components: example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: + anyOf: + - type: integer + format: int64 + minimum: 1 + - type: 'null' + description: Largest amount a single card transaction may authorize, in the smallest unit of the card's `currency`. Null means the card has no application-enforced per-transaction limit. A transaction for exactly this amount is allowed. + example: 5000 currency: type: string description: Currency the card transacts in (ISO 4217 for fiat, tickers for crypto). Derived from the funding sources at issue time — all funding sources bound to a card must be denominated in the same card-eligible currency. @@ -24374,15 +24369,21 @@ components: $ref: '#/components/schemas/CardForm' fundingSources: type: array - description: Internal account ids to bind as funding sources, in priority order. The first entry is tried first by Authorization Decisioning. Every card must be bound to at least one source, and every source must belong to the cardholder and be denominated in a card-eligible currency (USDB in v1); otherwise the request is rejected with `FUNDING_SOURCE_INELIGIBLE`. + description: Internal account ids to bind as funding sources, in priority order. The first entry is tried first by Authorization Decisioning. Every card must be bound to at least one source, and every source must belong to the cardholder and be denominated in a card-eligible currency; otherwise the request is rejected with `FUNDING_SOURCE_INELIGIBLE`. minItems: 1 items: type: string example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: + type: integer + format: int64 + minimum: 1 + description: Optional largest amount a single card transaction may authorize, in the smallest unit of the card currency derived from its funding sources. Omit this field for no limit. Supported only for card programs whose authorization decisions are made by Grid. A transaction for exactly this amount is allowed. + example: 5000 CardUpdateRequest: type: object - description: Update request for `PATCH /cards/{id}`. At least one of `state` or `fundingSources` must be supplied. `state` transitions are limited to `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`; any other transition returns `409 INVALID_STATE_TRANSITION`. `CLOSED` is terminal and irreversible and cannot be combined with `fundingSources`. `fundingSources`, when supplied, fully replaces the card's bound funding sources — the array order determines the priority Authorization Decisioning tries them in. + description: Update request for `PATCH /cards/{id}`. At least one of `state`, `fundingSources`, or `maxSpendPerTransaction` must be supplied. `state` transitions are limited to `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`; any other transition returns `409 INVALID_STATE_TRANSITION`. `CLOSED` is terminal and irreversible and cannot be combined with `fundingSources` or `maxSpendPerTransaction`. `fundingSources`, when supplied, fully replaces the card's bound funding sources — the array order determines the priority Authorization Decisioning tries them in. properties: state: type: string @@ -24401,6 +24402,14 @@ components: example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: + anyOf: + - type: integer + format: int64 + minimum: 1 + - type: 'null' + description: 'Replacement per-transaction spending limit for the card, in the smallest unit of its currency. Omit this field to leave the current limit unchanged, supply null to clear it, or supply a positive integer to set it. Supported only for card programs whose authorization decisions are made by Grid. Cannot be supplied alongside `state: CLOSED`.' + example: 10000 CardRevealResponse: type: object required: diff --git a/openapi/components/schemas/auth/DelegatedKey.yaml b/openapi/components/schemas/auth/DelegatedKey.yaml index aef934856..9f643eb63 100644 --- a/openapi/components/schemas/auth/DelegatedKey.yaml +++ b/openapi/components/schemas/auth/DelegatedKey.yaml @@ -48,14 +48,6 @@ properties: example: Settlement service key status: $ref: ./DelegatedKeyStatus.yaml - spendingLimits: - type: array - uniqueItems: true - description: >- - Per-transaction spending limits the key was created with, at most one - entry per currency. Absent when the key has no limits. - items: - $ref: ./DelegatedKeySpendingLimit.yaml createdAt: type: string format: date-time diff --git a/openapi/components/schemas/auth/DelegatedKeyCreateRequest.yaml b/openapi/components/schemas/auth/DelegatedKeyCreateRequest.yaml index 0f48939a9..be84ac495 100644 --- a/openapi/components/schemas/auth/DelegatedKeyCreateRequest.yaml +++ b/openapi/components/schemas/auth/DelegatedKeyCreateRequest.yaml @@ -24,15 +24,3 @@ properties: maxLength: 256 description: Human-readable label for the delegated key. example: Card payments key - spendingLimits: - type: array - uniqueItems: true - description: >- - Optional per-transaction spending limits for the key, at most one entry - per currency — a request with duplicate currency entries is rejected. - Grid enforces the limits when authorizing card transactions funded by - the key's Embedded Wallet account; a currency with no entry is - unlimited. Immutable — revoke the key and create a new one to change - limits. - items: - $ref: ./DelegatedKeySpendingLimit.yaml diff --git a/openapi/components/schemas/auth/DelegatedKeySpendingLimit.yaml b/openapi/components/schemas/auth/DelegatedKeySpendingLimit.yaml deleted file mode 100644 index 908691f0d..000000000 --- a/openapi/components/schemas/auth/DelegatedKeySpendingLimit.yaml +++ /dev/null @@ -1,22 +0,0 @@ -title: Delegated Key Spending Limit -type: object -required: - - currencyCode - - maxPerTransaction -properties: - currencyCode: - type: string - pattern: '^[A-Z0-9]{3,16}$' - description: >- - Uppercase alphanumeric currency code the limit applies to — ISO 4217 for - fiat (e.g. USD), or a Grid token code for stablecoins (e.g. USDB). Must - match the card's currency; requests with any other currency are rejected. - example: USD - maxPerTransaction: - type: integer - format: int64 - minimum: 1 - description: >- - Largest amount a single card transaction may authorize, in the smallest - unit of the currency (e.g., cents for USD). - example: 5000 diff --git a/openapi/components/schemas/cards/Card.yaml b/openapi/components/schemas/cards/Card.yaml index f9448354d..222790703 100644 --- a/openapi/components/schemas/cards/Card.yaml +++ b/openapi/components/schemas/cards/Card.yaml @@ -5,6 +5,7 @@ required: - state - form - fundingSources + - maxSpendPerTransaction - createdAt - updatedAt properties: @@ -61,6 +62,18 @@ properties: example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: + anyOf: + - type: integer + format: int64 + minimum: 1 + - type: 'null' + description: >- + Largest amount a single card transaction may authorize, in the smallest + unit of the card's `currency`. Null means the card has no + application-enforced per-transaction limit. A transaction for exactly + this amount is allowed. + example: 5000 currency: type: string description: >- diff --git a/openapi/components/schemas/cards/CardCreateRequest.yaml b/openapi/components/schemas/cards/CardCreateRequest.yaml index 3de99ac8a..32424b413 100644 --- a/openapi/components/schemas/cards/CardCreateRequest.yaml +++ b/openapi/components/schemas/cards/CardCreateRequest.yaml @@ -36,10 +36,21 @@ properties: The first entry is tried first by Authorization Decisioning. Every card must be bound to at least one source, and every source must belong to the cardholder and be denominated in a card-eligible - currency (USDB in v1); otherwise the request is rejected with + currency; otherwise the request is rejected with `FUNDING_SOURCE_INELIGIBLE`. minItems: 1 items: type: string example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: + type: integer + format: int64 + minimum: 1 + description: >- + Optional largest amount a single card transaction may authorize, in the + smallest unit of the card currency derived from its funding sources. + Omit this field for no limit. Supported only for card programs whose + authorization decisions are made by Grid. A transaction for exactly this + amount is allowed. + example: 5000 diff --git a/openapi/components/schemas/cards/CardUpdateRequest.yaml b/openapi/components/schemas/cards/CardUpdateRequest.yaml index 4664e3ecb..6f412736a 100644 --- a/openapi/components/schemas/cards/CardUpdateRequest.yaml +++ b/openapi/components/schemas/cards/CardUpdateRequest.yaml @@ -1,10 +1,11 @@ type: object description: >- - Update request for `PATCH /cards/{id}`. At least one of `state` or - `fundingSources` must be supplied. `state` transitions are limited to - `ACTIVE ⇄ FROZEN` and `ACTIVE | FROZEN → CLOSED`; any other transition - returns `409 INVALID_STATE_TRANSITION`. `CLOSED` is terminal and - irreversible and cannot be combined with `fundingSources`. + Update request for `PATCH /cards/{id}`. At least one of `state`, + `fundingSources`, or `maxSpendPerTransaction` must be supplied. `state` + transitions are limited to `ACTIVE ⇄ FROZEN` and + `ACTIVE | FROZEN → CLOSED`; any other transition returns + `409 INVALID_STATE_TRANSITION`. `CLOSED` is terminal and irreversible and + cannot be combined with `fundingSources` or `maxSpendPerTransaction`. `fundingSources`, when supplied, fully replaces the card's bound funding sources — the array order determines the priority Authorization Decisioning tries them in. @@ -36,3 +37,16 @@ properties: example: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: + anyOf: + - type: integer + format: int64 + minimum: 1 + - type: 'null' + description: >- + Replacement per-transaction spending limit for the card, in the smallest + unit of its currency. Omit this field to leave the current limit + unchanged, supply null to clear it, or supply a positive integer to set + it. Supported only for card programs whose authorization decisions are + made by Grid. Cannot be supplied alongside `state: CLOSED`. + example: 10000 diff --git a/openapi/paths/cards/cards.yaml b/openapi/paths/cards/cards.yaml index 9d945fc07..b8c17eef0 100644 --- a/openapi/paths/cards/cards.yaml +++ b/openapi/paths/cards/cards.yaml @@ -7,6 +7,13 @@ post: with `CARDHOLDER_KYC_NOT_APPROVED`. + An optional `maxSpendPerTransaction` value sets the largest amount a single card + transaction may authorize. The limit is enforced by Grid for card programs + where Grid makes the authorization decision, whether the card is funded by + an Embedded Wallet account or custodial fiat. Omit it for no limit. The + value is in the smallest unit of the card's currency. + + If any funding source is an Embedded Wallet internal account, the cardholder must authorize Grid to sign Spark token transactions for that card funding source by completing the delegated-key creation flow with @@ -39,6 +46,7 @@ post: form: VIRTUAL fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 responses: '201': description: >- diff --git a/openapi/paths/cards/cards_{id}.yaml b/openapi/paths/cards/cards_{id}.yaml index ea6aa935b..dd0c18388 100644 --- a/openapi/paths/cards/cards_{id}.yaml +++ b/openapi/paths/cards/cards_{id}.yaml @@ -54,8 +54,8 @@ get: patch: summary: Update a card description: > - Update a card's `state` and / or its bound `fundingSources`. At least - one of the two fields must be supplied. + Update a card's `state`, bound `fundingSources`, and / or + `maxSpendPerTransaction`. At least one field must be supplied. - `state` transitions are limited to `ACTIVE ⇄ FROZEN` and @@ -69,6 +69,13 @@ patch: one source. `fundingSources` cannot be supplied alongside `state: CLOSED`. + - `maxSpendPerTransaction`, when supplied, replaces the card's + application-enforced per-transaction limit. Supply a positive integer in + the smallest unit of the card's currency to set it or null to clear it. + Limits are supported only for card programs where Grid makes the + authorization decision. `maxSpendPerTransaction` cannot be supplied + alongside `state: CLOSED`. + This endpoint is authenticated by the platform credential alone and returns `200` directly. It deliberately does not use Grid's 202 → @@ -135,6 +142,14 @@ patch: fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + setSpendingLimit: + summary: Set the card's per-transaction spending limit + value: + maxSpendPerTransaction: 10000 + clearSpendingLimit: + summary: Remove the card's per-transaction spending limit + value: + maxSpendPerTransaction: null freezeAndUpdateSources: summary: Freeze the card and replace its funding sources in one call value: diff --git a/openapi/webhooks/card-funding-source-change.yaml b/openapi/webhooks/card-funding-source-change.yaml index ff9174211..07894542c 100644 --- a/openapi/webhooks/card-funding-source-change.yaml +++ b/openapi/webhooks/card-funding-source-change.yaml @@ -60,6 +60,7 @@ post: fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 - InternalAccount:019542f5-b3e7-1d02-0000-000000000003 + maxSpendPerTransaction: null currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-08T14:30:00Z' diff --git a/openapi/webhooks/card-state-change.yaml b/openapi/webhooks/card-state-change.yaml index c6d8caba0..ae8776f93 100644 --- a/openapi/webhooks/card-state-change.yaml +++ b/openapi/webhooks/card-state-change.yaml @@ -60,6 +60,7 @@ post: expYear: 2029 fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 currency: USD processorRef: card_b81c2a4f issuerRef: lead_card_7a1b9c3d @@ -79,6 +80,7 @@ post: form: VIRTUAL fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: null currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-08T14:12:00Z' @@ -100,6 +102,7 @@ post: expYear: 2029 fundingSources: - InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + maxSpendPerTransaction: 5000 currency: USD createdAt: '2026-05-08T14:10:00Z' updatedAt: '2026-05-09T09:00:00Z'