Skip to content

Partner Card API

The Partner Card API gives external card-issuing partners programmatic access to card management via a unified GraphQL endpoint. Partners can query card data (via Hasura) and execute card operations (via the card issuer) from the same URL.

Endpoints:

EnvironmentURL
Productionhttps://api.agiodigital.com/partner/cards/graphql
Developmenthttps://dev.api.agiodigital.com/partner/cards/graphql

Your api_token and client_secret are scoped to one environment; dev credentials never work against prod and vice versa.

Interactive explorer: open the endpoint URL in a browser to access the built-in GraphiQL IDE with HMAC signing.

Access Required

Partner API credentials are provisioned by Agio ops. Contact your Agio account manager to request access. You will receive an api_token UUID and a client_secret for request signing.

Download the starter kit

A minimal Bun starter kit — a bare-bones Express webhook receiver (HMAC verify) plus an end-to-end card-creation script — is ready to download and run:

⬇ Download webhook-starter-kit.zip

bash
unzip webhook-starter-kit.zip && cd webhook-starter-kit
cp .env.example .env   # fill in your credentials
bun install && bun receive

See the bundled README.md for the card-provisioning script (bun provision).

Quickstart — fund a card end to end

New here? This is the whole happy path. Each step links to its full detail below.

  1. Authenticate every request with x-agio-api-key + an HMAC signature. → Authentication · Signing Requests
  2. Provision a customer org, then a cardholder: createPartnerCustomerOrganizationcreatePartnerCardUser. → Onboarding Flow
  3. Create the application with a wallet: createCardApplicationForPartnerUser(input: { createWallet: true, … }), poll AgioCard_card_application until APPROVED/ACTIVE, then createCard. → Onboarding Flow
  4. Fund the card: read its deposit_address + deposit_chain_id and send the matching stablecoin there. Dev: testnets — Eth Sepolia (11155111) / Base Sepolia (84532), e.g. rUSD on Base Sepolia. Prod: USDC on Base (8453). → Funding cards
  5. Confirm it landed: AgioCard_vw_card_token_balance (on-chain) + cardBalance (credit/spending). → Funding cards

The two things that trip people up

  1. Send funds to the card's deposit_address (from AgioCard_card_application), not the cardholder's personal wallet. This is the #1 mistake.
  2. Pass createWallet: true when creating the application, or it has no deposit_address to fund.

There is no smartWalletSwap in the partner flow — that's the in-app user flow. Partners just send the stablecoin to deposit_address.

GraphiQL Explorer

Open https://api.agiodigital.com/partner/cards/graphql in your browser to access the interactive GraphQL IDE. The explorer includes:

  • Schema browser — browse all available queries and mutations without credentials (introspection is unauthenticated)
  • Credential panel — paste your api_token and client_secret to execute signed requests directly from the browser
  • Auto-signing — the explorer signs every POST request with HMAC-SHA256 using your client secret via WebCrypto

Credentials are stored in sessionStorage only and cleared when the tab closes.

Authentication

Every execution request requires two layers of authentication. Schema introspection and the GraphiQL explorer work with just the API key (no signature needed).

Your API credentials

Agio provisions two distinct values for you during onboarding — one identifies you, the other signs your requests:

CredentialHeaderRole
api_tokenx-agio-api-keyUUID that identifies your partner organization.
client_secret(signs x-agio-signature)agc_-prefixed key used to HMAC-sign each request — see Signing Requests.

The client_secret plaintext is shown once, at issuance, then stored only KMS-encrypted — Agio cannot read it back from any API or admin surface. If you lose it, ask your Agio account manager to rotate it; you'll receive a new client_secret (also shown once) and the old one stops working.

Two organization IDs, do not confuse them

Partner integrations involve two different organization IDs with opposite semantics:

  • partnerOrganizationId is your own organization's identity. You never send it. The API derives it from your API key on every request; it cannot be passed or overridden by the client, and it appears only in responses. All your data is automatically scoped to it.
  • customerOrganizationId is returned by createPartnerCustomerOrganization. This is the only organization ID you supply: you pass it to createPartnerCardUser and other customer-scoped calls.

Layer 1 — API Key

Send your api_token as the x-agio-api-key header:

x-agio-api-key: <your-api-token-uuid>

Layer 2 — HMAC Signature

Sign each execution request with your client_secret using HMAC-SHA256 over `${timestamp}.${rawBody}`:

HeaderValue
x-agio-timestampCurrent Unix time in milliseconds (Date.now())
x-agio-signatureHMAC-SHA256 hex digest of `${timestamp}.${rawBody}`

Timestamp in Milliseconds

The timestamp must be in milliseconds (e.g. 1744736599000), not seconds. Requests outside ±5 minutes of server time are rejected.

Introspection exception: queries containing only __schema or __type fields bypass the HMAC check — you can discover the schema with just your API key.

Replay protection: each (token, timestamp, signature) tuple is accepted only once within a 10-minute window.

Signing Requests

Using the Layer 2 — HMAC Signature mechanic above (HMAC-SHA256 hex digest over `${timestamp}.${rawBody}`, timestamp in ms), sign each execution request, then pick your language (the bash tab is handy for quick manual testing or shell scripts):

typescript
import { createHmac } from "node:crypto";

const endpoint = "https://api.agiodigital.com/partner/cards/graphql";
const apiToken = process.env.AGIO_PARTNER_API_TOKEN!;
const clientSecret = process.env.AGIO_PARTNER_CLIENT_SECRET!;

async function partnerQuery(query: string, variables?: Record<string, unknown>) {
  const body = JSON.stringify({ query, variables });
  const ts = Date.now().toString();
  const sig = createHmac("sha256", clientSecret).update(`${ts}.${body}`).digest("hex");

  const res = await fetch(endpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-agio-api-key": apiToken,
      "x-agio-timestamp": ts,
      "x-agio-signature": sig
    },
    body
  });

  return res.json();
}
python
import hmac, hashlib, time, json, os, urllib.request

def partner_query(query: str, variables: dict | None = None) -> dict:
    endpoint = "https://api.agiodigital.com/partner/cards/graphql"
    api_token = os.environ["AGIO_PARTNER_API_TOKEN"]
    client_secret = os.environ["AGIO_PARTNER_CLIENT_SECRET"]

    body = json.dumps({"query": query, "variables": variables}).encode()
    ts = str(int(time.time() * 1000))
    sig = hmac.new(client_secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()

    req = urllib.request.Request(endpoint, data=body, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("x-agio-api-key", api_token)
    req.add_header("x-agio-timestamp", ts)
    req.add_header("x-agio-signature", sig)

    with urllib.request.urlopen(req, timeout=15) as r:
        return json.loads(r.read())
bash
ENDPOINT="https://api.agiodigital.com/partner/cards/graphql"
BODY='{"query":"{ AgioCard_card_company { id } }"}'
TS=$(python3 -c 'import time; print(int(time.time()*1000))')
SIG=$(printf '%s' "${TS}.${BODY}" | openssl dgst -sha256 -hmac "$AGIO_PARTNER_CLIENT_SECRET" -hex | awk '{print $NF}')

curl -sS -X POST "$ENDPOINT" \
  -H "content-type: application/json" \
  -H "x-agio-api-key: $AGIO_PARTNER_API_TOKEN" \
  -H "x-agio-timestamp: $TS" \
  -H "x-agio-signature: $SIG" \
  -d "$BODY"

Expected: {"data":{"AgioCard_card_company":[...]}}. A 401 means your signature is off — check the Best practices section for common causes.

Verify your setup

Before writing integration code, confirm your API token is active and your environment is reachable. Introspection works with just the API key — no HMAC signing required — so this is the fastest smoke test:

bash
curl -sS -X POST https://api.agiodigital.com/partner/cards/graphql \
  -H "content-type: application/json" \
  -H "x-agio-api-key: $AGIO_PARTNER_API_TOKEN" \
  -d '{"query":"{ __schema { queryType { name } mutationType { name } } }"}'

Expected response:

json
{
  "data": {
    "__schema": {
      "queryType": { "name": "Query" },
      "mutationType": { "name": "Mutation" }
    }
  }
}

If you see {"error":"Unauthorized","description":"Invalid API key"} instead, the token is either mistyped, revoked, or scoped to the wrong environment. Contact your Agio account manager to verify.

To test end-to-end signing, run the Node.js or Python example above with a cardBalance query (expect a success: false, error: "Card not found" if you don't have a card ID yet — that confirms the sign-and-execute path works).

If signing fails with 401

The two most common causes (in order) are:

  1. Timestamp in seconds, not milliseconds — the server expects Date.now() (e.g. 1744736599000). date +%s on macOS gives seconds; use node -e 'process.stdout.write(Date.now().toString())' or python3 -c 'import time; print(int(time.time()*1000))'.
  2. Body serialization drift — the JSON you sign must match the JSON you send byte-for-byte. Don't re-serialize between signing and sending; pass the same body string to both.

See Best practices for the full list.

Schema Overview

The partner endpoint is a unified graph stitched from two sources:

SourcePrefixOperationsDescription
HasuraAgioCard_*full read surface (dynamic)Read card data, users, balances, transactions, wallets
Platform APICard* / utility namescurated allowlist (card lifecycle, ops, fees)Onboarding, card lifecycle, operations, fees

The AgioCard_* read surface is exposed dynamically via Hasura introspection filtering — every partner-visible card table yields its own root fields, so there is no fixed operation count. The Platform-API side is a curated allowlist (card lifecycle, funding, address validation, webhook management) — currently ~34 operations (1 query, cardBalance, + 33 mutations).

All results are automatically scoped to your partner organization.

Available Mutations

Onboarding:createPartnerCustomerOrganization · createPartnerCardUser · createCardApplicationForPartnerUser

Encryption session (PIN / reveal handshake):generateEncryptionKeys

Card lifecycle:createCard · replaceCard · replaceVirtualCard · cancelCard

Card operations:freezeCard · unfreezeCard · revealCardSecrets · setCardPin · getCardPin · updateCardNickname · updateCardLimit

lockCard / unlockCard are aliases

lockCard and unlockCard resolve to the same underlying operation as freezeCard / unfreezeCard. They are deprecated aliases — use freezeCard / unfreezeCard in new code.

Profile & address:updateCardUserProfile · updateCardCompanyAddress · validateAddress · validateCardShippingAddress · autocompleteAddress · resolvePlaceAddress

Funding:chargeCard

Smart-wallet treasury:multiSendFromWallet · companion query multiSendJobStatus

Webhook subscriptions:subscribePartnerWebhook · unsubscribePartnerWebhook · rotatePartnerWebhookSecret

Not exposed via this endpoint

  • createCardApplication — use createCardApplicationForPartnerUser instead (partner-specific resolver that skips Agio-user KYC)
  • createCardCorporateApplication — not currently supported for partners; the underlying resolver requires an authenticated Agio user in context. Contact your Agio account manager if you need corporate applications provisioned (Agio ops can create them on your behalf).
  • cardWithdraw — not currently exposed to partners (partner withdrawal runs through cardWithdrawForPartner)
  • payInvoiceWithCardBalance — Agio-internal billing flow, not relevant to partner integrations
  • All *ByCardId / *ByCardUserId admin-bypass operations

These mutations may appear in schema introspection (they're part of the underlying SDL) but execution is blocked at the Shield layer with extensions.code === "FORBIDDEN".

Rate Limits

Rate limits are enforced per identity (partner key + client IP). Crossing a limit returns extensions.code === "RATE_LIMIT_EXCEEDED" with a Retry-After hint — back off and retry.

Layer 1 — Global endpoint cap. A hard ceiling of ~2000 requests/minute per identity applies to every operation, regardless of the per-operation limits below.

Layer 2 — Per-operation limits. On top of the global cap, individual operations carry the limits below:

LimitWindowOperations
50 req1 minCard lifecycle: freezeCard, unfreezeCard, lockCard, unlockCard, cancelCard, createCard, replaceCard, replaceVirtualCard, updateCardNickname, updateCardCompanyAddress, createPartnerCardUser, cardWithdrawForPartner, cardFundSubClientFromTreasury
5 req1 minSensitive: revealCardSecrets, setCardPin, getCardPin, updateCardUserProfile, generateEncryptionKeys
20 req1 minAddress autocomplete: autocompleteAddress, resolvePlaceAddress
10 req1 minAddress validation: validateAddress, validateCardShippingAddress
5 req10 min (per org, sliding)multiSendFromWallet — exceeding returns RATE_LIMITED with retryAfterSec
global cap onlyAll other operations, e.g. cardBalance, cardFundSubClientStatus, multiSendJobStatus, chargeCard, updateCardLimit, createPartnerCustomerOrganization, createCardApplicationForPartnerUser

Upstream card-processor throttles surface separately as CARD_RATE_LIMITED (see Error Reference) — retry with exponential backoff starting at 1s.

Onboarding Flow

Bring-your-own KYC

If your KYC workspace is registered with Agio, you can skip the off-API KYC packet handover by passing partnerKycShareToken directly on createCardApplicationForPartnerUser. See Individual card application below.

Provision a customer organization

graphql
mutation {
  createPartnerCustomerOrganization(input: { name: "Acme Corp" }) {
    success
    organizationId
  }
}

Each customer organization is scoped to your partner account and isolated from other partners. The returned organizationId is what you'll pass as customerOrganizationId on createPartnerCardUser and other org-scoped partner mutations.

Provision a cardholder

graphql
mutation {
  createPartnerCardUser(
    input: {
      customerOrganizationId: "<organizationId from createPartnerCustomerOrganization>"
      firstName: "Alice"
      lastName: "Tester"
      email: "[email protected]"
      addressLine1: "1 Main St"
      addressCity: "Brooklyn"
      addressRegion: "NY"
      addressPostalCode: "11201"
      addressCountryCode: "US"
      phoneCountryCode: "1"
      phoneNumber: "5551234567"
      birthDate: "1990-01-15"
      nationalId: "123-45-6789"
    }
  ) {
    success
    cardUserId
    reused
    errorCode
    errorMessage
  }
}

Persist the returned cardUserId (UUID). It's stable across all future operations on this cardholder — pass it as cardUserId on subsequent createCardApplicationForPartnerUser calls.

Idempotency

createPartnerCardUser is idempotent on (customerOrganizationId, email). Re-running with the same email under the same customer org returns the existing cardUserId with reused: true — no duplicate row created.

nationalId is encrypted at rest

The nationalId you provide is encrypted on the way into storage and decrypted only at request-time when we forward it to the card processor. Plaintext never lands on disk.

Individual card application (partner-provisioned user)

graphql
mutation {
  createCardApplicationForPartnerUser(
    input: {
      cardUserId: "external-card-user-uuid"
      # Provide either walletAddress OR createWallet: true (mutually exclusive).
      walletAddress: "0x1234...abcd"
      # createWallet: true   # Agio registers your Treasury wallet as this sub-client's collateral admin (treasury must be configured).
      occupation: "Software Developers"
      annualSalary: "100000"
      accountPurpose: "Business expenses"
      expectedMonthlyVolume: "5000"
      isTermsOfServiceAccepted: true
      # Optional: forward your KYC provider's share-token to skip Agio-side KYC handover.
      # partnerKycShareToken: "eJhbGc...short-lived-opaque-token"
    }
  ) {
    success
    applicantId
    cardApplicationId
    cardApplicationExternalId
    applicationStatus
    applicationCompletionUrl
    error
  }
}

occupation must be a valid SOC code ("15-1252") or description ("Software Developers") — see Occupation Codes for the full list and lookup guidance.

partnerKycShareToken — bypass off-API KYC handover

If your KYC provider workspace is registered with Agio (one-time onboarding step — contact your Agio account manager), you can pass a fresh share-token from your workspace as partnerKycShareToken. Agio forwards it to the issuer instead of requiring a pre-populated KYC packet on the Agio side.

  • Token shape: opaque string from your KYC provider's share-token endpoint.
  • Lifetime: short-lived (typically minutes-to-hours). Generate one per application; don't cache.
  • Errors: expired/replayed tokens return KYC_TOKEN_EXPIRED — retry with a fresh token. An unregistered workspace returns KYC_WORKSPACE_NOT_AUTHORIZED — start onboarding.

When omitted, Agio falls back to the direct-PII path described in the precondition info-box below.

cardUserId is the UUID returned by createPartnerCardUser for this cardholder. The response echoes it back as applicantId, and provides two application identifiers:

  • cardApplicationId (Int) — internal numeric id from AgioCard_card_application.id. Pass this directly to createCard(input: { cardApplicationId, ... }) once the application is APPROVED or ACTIVE.
  • cardApplicationExternalId (String) — issuer-side application UUID. Persist this for cross-referencing with the issuer's records and for joining against AgioCard_card_application.card_application_external_id in subsequent Hasura queries.

Two KYC paths, your choice

Path A — direct PII (default). You've already collected the cardholder's identity via createPartnerCardUser (firstName/lastName/email/birthDate/nationalId/phone/address). The mutation forwards them sibling-level to the card processor — no KYC workspace integration required. Best for partners running their own KYC and just wanting card issuance.

Path B — share-token bypass. If your KYC provider workspace is registered with Agio (one-time onboarding), pass partnerKycShareToken from your workspace to skip Agio re-verifying the documents. Cardholder PII still forwarded from your createPartnerCardUser record alongside the token — required by the card processor's body validator.

If the cardUser PII is missing required fields (firstName / lastName / email / birthDate / nationalId / phone / address) you'll see CARD_API_ERROR with a payload-schema message from the card processor. Fix the underlying createPartnerCardUser data and retry.

Where does walletAddress come from?

walletAddress is the EVM address (0x...) that becomes the collateral admin of the sub-client's card contract — the wallet whose signature authorizes funding and withdrawal of that cardholder's collateral. Supply your customer's own EVM address only if they will self-custody and sign for their own collateral — note that a self-custody sub-client cannot be withdrawn via cardWithdrawForPartner, which always signs with your Treasury. For partner-managed withdrawal, use createWallet: true (below).

If you'd rather control collateral centrally, pass createWallet: true instead — Agio registers your configured Treasury wallet as the collateral admin for this sub-client (see the tip below), so a single wallet you already control administers every sub-client.

createWallet: true — use your Treasury as the collateral admin

Pass createWallet: true in place of walletAddress and Agio registers your configured Treasury wallet as this sub-client's on-chain collateral admin:

  • One admin for every sub-client. All sub-clients you provision this way share your Treasury wallet as their collateral admin, so the Treasury funds and withdraws on their behalf — you never manage a separate wallet per cardholder.
  • Treasury must be configured. Your partner Treasury is provisioned during onboarding (contact your Agio account manager). If it isn't configured, createWallet: true returns VALIDATION_ERROR.
  • Re-query. The mutation echoes your Treasury address on CardApplicationResponse.walletAddress; you can also read it later from AgioCard_card_application.wallet_address (via Hasura) — the same Treasury address appears on every sub-client provisioned this way.
  • Validation. walletAddress and createWallet are mutually exclusive. Supplying both — or neither — returns VALIDATION_ERROR.

Partner-issued cards have no user_id

Cards created from a partner-flow application have user_id = NULL (there's no Agio user behind them) and card_company_id set instead. When scoping queries to your cards, join via card_company_id → card_company.organization_id rather than user_id.

Application lifecycle — polling for status transitions

After createCardApplicationForPartnerUser, the application moves through statuses (PENDINGIN_REVIEWAPPROVEDACTIVE) as the underlying KYC/KYB checks complete and the card contract deploys. Poll AgioCard_card_application to detect APPROVED / ACTIVE before calling createCard.

This is the production-supported v1 pattern. Outbound webhook delivery is also available — see Webhook subscriptions below. Poll + webhook can run in parallel; the deterministic eventId lets you dedup safely.

graphql
query CardApplicationStatus($cardUserId: uuid!, $since: timestamptz) {
  AgioCard_card_application(where: { card_user_id: { _eq: $cardUserId }, updated_at: { _gte: $since } }, order_by: { updated_at: desc }, limit: 1) {
    id
    card_application_external_id
    application_status
    updated_at
  }
}

Polling guidance:

  • Interval floor: ≥60s per cardUserId. Sub-60s polling returns CARD_RATE_LIMITED with a Retry-After header.
  • Cursor: Pass the last updated_at you observed as the since variable for incremental polling — avoids re-fetching unchanged rows.
  • Tenant scope: results are filtered to your customer organizations automatically; cross-tenant queries return empty results (existence-hidden).
  • Indexed: the query above is sub-millisecond at scale — designed for tight polling loops.
  • Terminal states: APPROVED and ACTIVE are go-states for createCard. REJECTED / CANCELLED are terminal failures — surface these to your end-user instead of retrying.

Webhook subscriptions

Subscribe Agio-side events to your own HTTPS receiver. Each delivery is HMAC-signed with a per-subscription secret returned once on subscribePartnerWebhook.

Runnable receiver

The starter kit ships a bare-bones Express receiver (verify-webhook.ts, one dependency) run by Bun that implements the HMAC verification described below. Clone the logic into your own endpoint.

Two distinct HMAC secrets per partner

  • API key + HMAC secret = inbound — partner → Agio. Used on every signed request to /partner/cards/graphql (x-agio-api-key + x-agio-signature). Issued during onboarding; rotates via Agio support.
  • Webhook signing secret = outbound — Agio → partner. Used to sign event POSTs your receiver verifies. Returned once by subscribePartnerWebhook; rotates via rotatePartnerWebhookSecret (24h grace).

Do not reuse one for the other.

Subscribed events

Event nameFires on
card_application.status_changedcard_application.application_status transitions (e.g. PENDING→APPROVED)
card_transaction.authorizeda card transaction is authorized — the first notification for a spend
card_transaction.settleda card transaction reaches its completed/settled state
card_transaction.declineda card transaction is declined
card_transaction.reverseda card transaction is reversed (refund/chargeback)

Subscribe with the enum name, not the wire name

The events argument to subscribePartnerWebhook takes the GraphQL enum (SCREAMING_SNAKE); Agio maps it to the dotted wire name you receive in x-agio-event and the body eventName:

Subscribe with (enum)You receive (wire name)
CARD_APPLICATION_STATUS_CHANGEDcard_application.status_changed
CARD_TRANSACTION_AUTHORIZEDcard_transaction.authorized
CARD_TRANSACTION_SETTLEDcard_transaction.settled
CARD_TRANSACTION_DECLINEDcard_transaction.declined
CARD_TRANSACTION_REVERSEDcard_transaction.reversed

Because events is a GraphQL enum, passing a dotted wire name — or any non-enum value — is rejected at schema validation, before the resolver runs: you get a top-level errors[] entry with extensions.code: BAD_USER_INPUT (e.g. Value "card_transaction.authorized" does not exist in "PartnerWebhookEvent" enum type), not the structured errorCode: INSERT_FAILED.

More event names land as the substrate grows. Subscribe to specific events you care about — unknown event names are rejected at subscribe-time.

Card spend and authorization events are delivered via webhook — subscribe to the card_transaction.* events above. Each fires exactly once per state transition (keyed on card_transaction.notification_state), so you receive one POST as a spend is authorized, one when it settles, and one if it is declined or reversed. You can still reconcile by polling AgioCard_card_transaction with the same updated_at cursor pattern used for application-status polling above, filtering transaction_type: { _eq: "spend" } (the table also records non-spend rows such as collateral, payments, and fees, so sum spend on the filtered set). The partner endpoint does not expose GraphQL subscriptions.

Subscribe

graphql
mutation Subscribe($input: SubscribePartnerWebhookInput!) {
  subscribePartnerWebhook(input: $input) {
    success
    subscriptionId
    signingSecret # returned ONCE — capture immediately, never readable again
    errorCode
    errorMessage
  }
}
json
{ "input": { "url": "https://your-domain.example/agio-webhooks", "events": ["CARD_APPLICATION_STATUS_CHANGED", "CARD_TRANSACTION_SETTLED"] } }

Send this against your environment's partner endpoint. Dev: https://dev.api.agiodigital.com/partner/cards/graphql, prod: https://api.agiodigital.com/partner/cards/graphql (the signed-request examples above hardcode the prod host; swap it for the dev host when testing in dev).

Subscribe result codes:

errorCodeMeaning
nullSuccess — capture signingSecret immediately
FORBIDDENCaller is not a partner organization
INSERT_FAILEDPersistence failed (DB / KMS / validation). Safe to retry after a short backoff.

Shortly after subscribe, a one-time test delivery arrives to verify your endpoint. It fires asynchronously (via an event trigger on INSERT, not synchronous with this mutation's response) and lands within seconds if your endpoint is publicly reachable. See Test delivery on subscribe below.

Receiver requirements

Your receiver must be publicly reachable

Before every send, the dispatcher runs an SSRF guard: HTTPS-only, and it rejects localhost, private IPs, and any hostname that DNS-resolves to a private IP (rebinding defense) — the full reject list (CIDR ranges, metadata hosts, and reason codes) is in the requirements below. So localhost, private IPs, and tunnels that resolve to a private address never receive deliveries, including the test ping.

For dev testing, point the subscription at a publicly-routable HTTPS endpoint:

  • webhook.site: instant public URL to inspect deliveries, no setup
  • a public ngrok / cloudflared tunnel URL (the tunnel's https://… host, not the local port)
  • a deployed receiver

If no test ping arrives, the URL almost certainly isn't publicly reachable; it's rarely a broken HMAC.

Your url must satisfy all of these or delivery is rejected before any HTTP request goes out (failure logged for audit; consecutive_failures bumped, auto-disabled after 5):

  • HTTPS only. http:// is rejected (INSECURE_PROTOCOL).
  • Publicly-routable host. Hostnames resolving to RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), loopback (127.0.0.0/8), link-local (169.254.0.0/16), shared/CGNAT (100.64.0.0/10), or cloud-metadata (169.254.169.254, metadata.google.internal, metadata.goog) are rejected. DNS rebinding (any returned A/AAAA being private) is also rejected.
  • No redirect chains. Outbound POSTs use redirect: "manual". A 3xx response is treated as a non-2xx failure → retry. Don't put a 302 in front of your receiver.

Delivery shape

POST <your-url>
content-type: application/json
user-agent: Agio-Webhooks/1.0 (+https://docs.agiodigital.com/guides/cards/partner-api.html#webhook-subscriptions)
accept: */*
x-agio-event: card_application.status_changed
x-agio-delivery-id: <unique per attempt — changes on each retry; use eventId in the body for cross-retry dedup>
x-agio-timestamp: <unix ms>
x-agio-signature: <hex hmac-sha256(signingSecret, `${timestamp}.${rawBody}`)>

{
  "eventName": "card_application.status_changed",
  "eventId":   "cas_<deterministic sha256 of (table, pk, updated_at)>",
  "deliveredAt": "2026-05-26T10:00:00.123Z",
  "data": {
    "cardApplicationId": 42,
    "cardApplicationExternalId": "ext-42",
    "cardUserId": "cu-…",
    "oldStatus": "PENDING",
    "newStatus": "APPROVED"
  }
}

A card_transaction.* event uses the same envelope with a transaction data block:

POST <your-url>
x-agio-event: card_transaction.settled
x-agio-delivery-id: <unique per attempt>
x-agio-timestamp: <unix ms>
x-agio-signature: <hex hmac-sha256(signingSecret, `${timestamp}.${rawBody}`)>

{
  "eventName": "card_transaction.settled",
  "eventId":   "ctx_<deterministic sha256 of (table, pk, updated_at)>",
  "deliveredAt": "2026-05-26T10:00:00.123Z",
  "data": {
    "cardApplicationId": 42,
    "cardId": "card_…",
    "cardTransactionId": "…",
    "externalId": "…",
    "transactionType": "spend",
    "amount": 12.34,
    "currency": "USD",
    "status": "completed",
    "notificationState": "completed_notified",
    "occurredAt": "2026-05-26T09:59:58.000Z"
  }
}

Which notificationState maps to which event — the notificationState in the data block tells you which card_transaction.* event this delivery is:

notificationStateEvent delivered
created_notifiedcard_transaction.authorized
completed_notifiedcard_transaction.settled
declined_notifiedcard_transaction.declined
reversed_notifiedcard_transaction.reversed

A transaction whose notificationState is none of these does not emit a partner event.

The User-Agent is stable — safe to allowlist in WAF rules or filter in receiver logs. Agio aborts the request after 10s total; design your receiver to ACK in under 1s and process asynchronously.

Verify the signature

Recompute the HMAC over `${timestamp}.${rawBody}` with your subscription's signing secret and compare in constant time, then pick your language:

typescript
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(req: { headers: Record<string, string>; rawBody: string }, signingSecret: string) {
  const ts = req.headers["x-agio-timestamp"];
  const sig = req.headers["x-agio-signature"];
  if (!ts || !sig) return false;
  const tsNum = Number(ts);
  if (!Number.isFinite(tsNum) || Math.abs(Date.now() - tsNum) > 5 * 60_000) return false; // 5min window
  const expected = createHmac("sha256", signingSecret).update(`${ts}.${req.rawBody}`).digest("hex");
  const sigBuf = Buffer.from(sig, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (sigBuf.length !== expBuf.length) return false; // timingSafeEqual throws on length mismatch
  return timingSafeEqual(sigBuf, expBuf);
}
python
import hmac, hashlib, time

def verify(headers: dict, raw_body: bytes, signing_secret: str) -> bool:
    ts = headers.get("x-agio-timestamp"); sig = headers.get("x-agio-signature")
    if not ts or not sig: return False
    try:
        ts_int = int(ts)
    except (TypeError, ValueError):
        return False
    if abs(int(time.time() * 1000) - ts_int) > 5 * 60 * 1000: return False
    expected = hmac.new(signing_secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

Test delivery on subscribe

When subscribePartnerWebhook succeeds, Agio enqueues a one-time synthetic delivery to your URL so you can verify HMAC + endpoint reachability without waiting for real card-application traffic. It fires asynchronously via an event trigger on the subscription INSERT (it is not synchronous with the mutation response) and arrives within seconds, provided your endpoint is publicly reachable (and the environment's event trigger is active):

POST <your-url>
x-agio-event: subscription.created
x-agio-delivery-id: <unique>
x-agio-timestamp: <unix ms>
x-agio-signature: <hex hmac-sha256(signingSecret, `${timestamp}.${rawBody}`)>

{
  "eventName": "subscription.created",
  "eventId": "test_<random uuid>",
  "deliveredAt": "...",
  "data": { "test": true, "message": "This is the one-time test delivery confirming your endpoint is reachable." }
}
  • eventId is prefixed test_ so your dedup table can distinguish it from real traffic (cas_… for application events, ctx_… for transaction events).
  • eventName: "subscription.created" is server-emitted only; you cannot subscribe to it explicitly. The event trigger fires it asynchronously on INSERT regardless of which events you registered for. A missing test ping almost always means the URL isn't publicly reachable, not a broken HMAC.
  • If your receiver returns non-2xx, the test delivery retries through the normal chain (30s/2m/…) and counts toward auto-disable. Fix your endpoint promptly to avoid the subscription being disabled before the first real event.

Dedup + retries

  • eventId is stable across retriessha256(table:pk:updated_at) truncated. Same logical event, same id, regardless of how many times you receive it. Store seen ids; ignore replays.
  • Retry schedule: Agio retries failed deliveries (non-2xx response or network error) at 30s, 2m, 10m, 1h, 6h.
  • Auto-disable: after 5 consecutive failures on a subscription, Agio sets is_active = false and drains pending retry jobs. No notification is sent — webhooks simply stop; detect this by polling your subscription status (is_active / consecutive_failures, documented in the subscription fields above). To re-enable, fix the receiver then subscribePartnerWebhook again (new subscription, new signing secret). The old subscription stays in partner_webhook_subscription with is_active = false for audit. Counter resets to 0 on any 2xx delivery — partial outages auto-heal without the threshold tripping.
  • Failure classification: the last_failure_reason column on the subscription captures the cause: http_<status> for issuer responses, an Error message for fetch/timeout failures, or ssrf:<REASON> if the URL was rejected before sending.
  • 2xx acks delivery. Any 4xx/5xx triggers retry. Respond 200 once your verification + dedup pass — process asynchronously to avoid retry storms if downstream is slow.

Rotate the signing secret

graphql
mutation {
  rotatePartnerWebhookSecret(subscriptionId: "<id>") {
    success
    signingSecret
    previousSecretValidUntil
    errorCode
    errorMessage
  }
}

Rotate result codes:

errorCodeMeaning
nullSuccess — capture new signingSecret; previous remains valid until previousSecretValidUntil (ISO 8601, +24h)
FORBIDDENCaller is not a partner organization
NOT_FOUNDSubscription doesn't exist or belongs to another organization
ROTATION_RACEA concurrent rotation completed first. Re-fetch the subscription (partner_webhook_subscription_by_pk) and decide whether to retry
UPDATE_FAILEDDB error during rotation; previous secret remains in effect

Grace window: Agio signs outbound deliveries with the new secret only. Your verifier must accept either secret for 24h to handle in-flight deliveries that were signed before your code picked up the rotation. After previousSecretValidUntil, drop the old secret.

List subscriptions (Hasura auto-query)

graphql
query {
  partner_webhook_subscription(where: { is_active: { _eq: true } }) {
    id
    url
    events
    consecutive_failures
    last_delivery_at
    last_failure_at
    last_failure_reason
  }
}

Scoped automatically to your customer organizations. The signing secret is never readable after creation — capture it from the subscribe/rotate response when it's returned, then store it in your secrets manager.

Delivery history

graphql
query History($sid: uuid!) {
  partner_webhook_delivery(where: { subscription_id: { _eq: $sid } }, order_by: { created_at: desc }, limit: 20) {
    event_name
    event_id
    http_status
    attempt_number
    delivered_at
    failed_at
    dead_lettered
  }
}

Unsubscribe

graphql
mutation {
  unsubscribePartnerWebhook(subscriptionId: "<id>") {
    success
    errorCode
    errorMessage
  }
}

Unsubscribe result codes:

errorCodeMeaning
nullSuccess
FORBIDDENCaller is not a partner organization
NOT_FOUNDSubscription doesn't exist or belongs to another organization
UPDATE_FAILEDDB error; retry safe

What happens after success:

  • The subscription row is marked is_active = false (soft-delete — history preserved for audit).
  • Queued + delayed retry jobs for this subscription are drained within seconds (no need to wait out the 6h max retry window). In-flight POSTs already running to completion will complete normally; the next pre-flight check skips inactive subs.
  • The subscription remains visible in partner_webhook_subscription queries with is_active = false for audit. Filter on {is_active: {_eq: true}} to see only live subscriptions.

PIN encryption (setCardPin / getCardPin)

PIN, change-PIN, and reveal-secrets all share one handshake. Call generateEncryptionKeys first — it returns a per-request { sessionId, key, iv } keyed to your partner organization. Encrypt the PIN with encryptPassphraseForTransfer from agio-utils, then call setCardPin. To read the PIN back (getCardPin) or the PAN/CVC (revealCardSecrets), generate a fresh session and decrypt the response with decryptWithSessionKey:

typescript
import { encryptPassphraseForTransfer, decryptWithSessionKey } from "agio-utils";

// 1. Handshake — sessionId is one-shot and tied to your partner_organization_id server-side.
const session = await partnerQuery("mutation { generateEncryptionKeys { sessionId key iv } }").then((r) => r.data.generateEncryptionKeys);

// 2. Set the PIN.
const { encryptedPassphrase } = await encryptPassphraseForTransfer(session, "7193");
await partnerQuery("mutation($input: SetCardPinInput!) { setCardPin(input: $input) { success error } }", {
  input: { cardId, sessionId: session.sessionId, encryptedPin: encryptedPassphrase }
});

// 3. Read it back — needs a fresh session, the set-side session is consumed.
const readSession = await partnerQuery("mutation { generateEncryptionKeys { sessionId key iv } }").then((r) => r.data.generateEncryptionKeys);
const { encryptedPin } = await partnerQuery("mutation($id: Int!, $session: String!) { getCardPin(cardId: $id, sessionId: $session) { encryptedPin } }", {
  id: cardId,
  session: readSession.sessionId
}).then((r) => r.data.getCardPin);
const pin = await decryptWithSessionKey(readSession.key, readSession.iv, encryptedPin);

// 4. revealCardSecrets follows the same shape; the response carries
//    encryptedSecrets that decrypts to JSON with { pan, cvc, expiry }.
//    expiry may be a string ("MM/YY") or an object — check at runtime.

Sessions are one-shot and partner-scoped

Each sessionId is consumed on first use. Always call generateEncryptionKeys immediately before each PIN/reveal operation; reusing a sessionId returns "session expired". The server validates the session's bound identity against your partner_organization_id from the token row — calling from a different partner token will reject with a generic 400.

PIN format

PINs must be 4–12 digits. No repeated digits (1111), no ascending sequence (1234), no descending sequence (4321).

Funding cards

There are two ways to add spending power to a card. Direct deposit is not a partner-API mutation — you send a supported stablecoin on-chain to the card's deposit address (below). Alternatively, if you hold collateral in a single Treasury wallet, cardFundSubClientFromTreasury moves funds from your Treasury into a sub-client for you. With direct deposit, the card processor picks up the deposit and credits the card balance — confirm it landed via the balance views below (AgioCard_vw_card_token_balance for the on-chain deposit, cardBalance / AgioCard_vw_card_user_balance for the resulting credit). There is no outbound funding webhook today. To move funds out of a smart wallet to many destinations (disbursements, sweeps, bulk payouts), see Multisend.

You operate a single Treasury wallet, which Agio registers as the on-chain collateral admin for every sub-client you provision with createWallet: true. Each sub-client has a collateral contract backing that cardholder's spending; because your Treasury administers that contract, it can move value in and out on the sub-client's behalf — no per-sub-client wallet is involved:

Fund:      Treasury wallet  ──ERC-20 transfer──▶  sub-client collateral   (cardFundSubClientFromTreasury)
Withdraw:  sub-client collateral  ──────────────▶  Treasury wallet        (cardWithdrawForPartner)

Find the deposit address

The funding (deposit) address is set on the card application — but only when it was created with createWallet: true (applications created with a partner-supplied walletAddress won't have one). Read it via Hasura:

graphql
query CardDepositAddresses {
  AgioCard_card_application(where: { deposit_address: { _is_null: false } }, order_by: { id: asc }) {
    id
    deposit_address # where to send funds
    deposit_chain_id # prod: 8453 (Base) · dev: 11155111 (Eth Sepolia) or 84532 (Base Sepolia)
    application_status
  }
}

Send the stablecoin to deposit_address on the chain identified by deposit_chain_id — read the chain from the row rather than hardcoding it. On dev, deposit addresses span testnets — Ethereum Sepolia (11155111) and Base Sepolia (84532); on prod, Base mainnet (8453).

Supported stablecoin

Send USDC on prod (Base mainnet). On dev the test collateral token depends on the deposit chain — e.g. rUSD on Base Sepolia (mint it from the rUSD sandbox faucet). Always read deposit_chain_id from the application row, then confirm what actually landed on-chain with the token-balance view below.

Check the on-chain balance on a card wallet

AgioCard_vw_card_token_balance reflects the live on-chain token balance held at each card's deposit address:

graphql
query CardTokenBalances {
  AgioCard_vw_card_token_balance {
    deposit_address
    chain_name
    token_symbol # USDC on prod · varies by chain on dev (e.g. rUSD on Base Sepolia)
    token_balance # on-chain balance
    token_balance_usd
    advance_rate # % of the deposit that counts toward spending power
  }
}

Check credit / spending power

AgioCard_vw_card_user_balance rolls the collateral up into the card user's credit line:

graphql
query CardUserBalances {
  AgioCard_vw_card_user_balance {
    card_user_id
    credit_limit
    collateral_balance
    spending_power
    balance_due
  }
}

Pull it all in one call

AgioCard_vw_card nests the card details, the deposit address (via card_application), and these balances (via balance) — see Client card info in one call to fetch a cardholder's full state in a single query.

Sample fund flow

  1. Read the application's deposit_address + deposit_chain_id (above) — created with createWallet: true
  2. Send the supported stablecoin (USDC on Base mainnet · the chain's test token on dev, e.g. rUSD on Base Sepolia) to that address
  3. Wait for the on-chain confirmation
  4. AgioCard_vw_card_token_balance.token_balance reflects the deposit; cardBalance(cardId) and AgioCard_vw_card_user_balance.spending_power reflect the new credit

chargeCard is for fees, not funding

chargeCard(cardUserId, feeCents, feeDescription) collects a fee from the cardholder's funded balance — it is the opposite of funding (debit, not credit). Use it for monthly service fees, late fees, etc., never as a top-up mechanism.

Fund a sub-client from your treasury (cardFundSubClientFromTreasury)

If you hold collateral in a single Treasury wallet instead of depositing to each card's address, cardFundSubClientFromTreasury moves USDC from your Treasury into a sub-client's collateral via an on-chain ERC-20 transfer — Agio signs it for you. The funding chain is taken from your treasury configuration (there is no chainId input).

This call is asynchronous: it enqueues a job and returns a jobId immediately; the on-chain transfer settles afterward.

Input fieldTypeDescription
externalUserIdString!The sub-client's card_user_id to fund.
amountCentsInt!Funding amount in integer cents.

The response reports only synchronous (enqueue-time) failures:

errorCodeWhen
INVALID_AMOUNTamountCents is not a positive integer.
SUBCLIENT_NOT_FOUNDUnknown sub-client, or not managed by your partner organization.
TREASURY_NOT_CONFIGUREDNo treasury configuration exists for your partner organization.
graphql
mutation {
  cardFundSubClientFromTreasury(input: { externalUserId: "9f3c…", amountCents: 50000 }) {
    success
    jobId
    errorCode
  }
}
json
{ "data": { "cardFundSubClientFromTreasury": { "success": true, "jobId": "b2e1…", "errorCode": null } } }

Poll funding status (cardFundSubClientStatus)

The background transfer settles after a successful enqueue, and it can still fail there (e.g. the sub-client's collateral contract isn't ready, a treasury-wallet mismatch, or an indeterminate send). Poll cardFundSubClientStatus(jobId) with the jobId from the enqueue response to observe the async outcome.

graphql
query FundSubClientStatus($jobId: String!) {
  cardFundSubClientStatus(jobId: $jobId) {
    success
    jobId
    status
    txHash
    failedReason
    failedCode
    attempts
    errorCode
    errorMessage
  }
}
json
{ "jobId": "b2e1…" }

Jobs advance pendingrunningsubmittedcompleted, branching to failed on rejection:

statusMeaningYour move
pendingQueued, not yet picked up.Keep polling.
runningWorker is resolving the sub-client + treasury config.Keep polling.
submittedBroadcast to the chain; awaiting confirmation.Keep polling for completed + txHash.
completedOn-chain and confirmed. txHash is set.Done.
failedRejected before or at broadcast; see failedReason.Read failedCode / failedReason, fix, resubmit.

Async failures surface here as status = failed with a human-readable failedReason and a machine-readable failedCode (e.g. COLLATERAL_CONTRACT_NOT_READY, TREASURY_WALLET_MISMATCH, SUBCLIENT_NOT_FOUND, TREASURY_NOT_CONFIGURED) — branch on failedCode, not on the free-text failedReason. The query-level errorCode (FORBIDDEN, NOT_FOUND, INTERNAL_ERROR) is separate and reports problems reading the job itself.

A job stuck in submitted is indeterminate, not failed

If the on-chain send outcome is unknown (a crash or timeout after broadcast), the job is left in submitted with failedCode = FUNDING_SEND_INDETERMINATE rather than flipped to failed — the transfer may already be on-chain. Do not resubmit a submitted job; that risks a double-send. Reconcile against the sub-client's on-chain collateral history (or ask Agio) to confirm whether it landed.

json
{
  "data": {
    "cardFundSubClientStatus": {
      "success": true,
      "jobId": "b2e1…",
      "status": "completed",
      "txHash": "0xabc123…",
      "failedReason": null,
      "failedCode": null,
      "attempts": 1
    }
  }
}

Belt-and-suspenders: still confirm the balance

The status query is the primary signal, but an out-of-band balance check remains a useful fallback — re-read the sub-client's balance (cardBalanceByExternalUserId or AgioCard_vw_card_user_balance.collateral_balance) until the collateral increase appears, especially when reconciling a submitted/indeterminate job.

Withdrawing collateral

cardWithdrawForPartner pulls USDC collateral from a sub-client's collateral contract back into your configured Treasury wallet. Because your Treasury is the contract's registered collateral admin, Agio produces the on-chain admin signature with your Treasury — no per-sub-client wallet is touched. Unlike funding, this call is synchronous — the response carries the on-chain result.

Requires the Treasury to be the sub-client's collateral admin

Withdrawal only works for sub-clients whose collateral admin is your Treasury — i.e. those provisioned with createWallet: true (or migrated to Treasury-admin). A sub-client created with a self-custody walletAddress is not withdrawable here, since the Treasury can't sign for a contract it doesn't administer.

The recipient is hard-locked

The destination is always the treasury_wallet_address from your treasury configuration — callers cannot supply a destination address. (An admin-curated recipient allowlist exists in the schema via upsertPartnerWithdrawalAllowlist, but that gate is not currently enforced — the recipient is hard-locked to your Treasury regardless.)

Input fieldTypeDescription
externalUserIdString!The sub-client's card_user_id.
amountCentsInt!Withdrawal amount in integer cents.
chainIdIntOptional. Defaults to your treasury config chain when omitted; an unsupported value returns INTERNAL_ERROR.
dryRunBooleanWhen true, validates and prices the withdrawal without submitting on-chain.
Response fieldTypeDescription
successBoolean!true when the withdrawal (or dry run) succeeded.
transactionHashStringOn-chain tx hash on a submitted (non-dry-run) success.
errorCodeStringOne of the codes below, on failure.
errorStringHuman-readable detail.
spendingPowerCentsIntAvailable spending power — on INSUFFICIENT_SPENDING_POWER and on a dry run.
requestedCentsIntThe requested amount echoed back — on INSUFFICIENT_SPENDING_POWER and a dry run.
retryAfterSecIntSeconds to wait before retrying — on CARD_SIGNING_IN_FLIGHT.
errorCodeWhen
INVALID_AMOUNTamountCents is not a positive integer.
SUBCLIENT_NOT_FOUNDexternalUserId is unknown, or the sub-client is not managed by your partner organization.
TREASURY_NOT_CONFIGUREDNo treasury configuration exists for your partner organization.
INSUFFICIENT_SPENDING_POWERThe requested amount exceeds the sub-client's available collateral (spendingPowerCents + requestedCents are populated).
CARD_SIGNING_IN_FLIGHTA concurrent signing operation is in flight for this sub-client — retry after retryAfterSec seconds. Match on this exact string.
INTERNAL_ERRORUnexpected server error (also returned for an unsupported chainId). Details are logged, not returned.

Always dry-run first to price a withdrawal and confirm spending power before submitting:

graphql
mutation {
  cardWithdrawForPartner(input: { externalUserId: "9f3c…", amountCents: 50000, dryRun: true }) {
    success
    spendingPowerCents
    requestedCents
  }
}
json
{ "data": { "cardWithdrawForPartner": { "success": true, "spendingPowerCents": 120000, "requestedCents": 50000 } } }

Drop dryRun to submit the withdrawal to your Treasury wallet:

graphql
mutation {
  cardWithdrawForPartner(input: { externalUserId: "9f3c…", amountCents: 50000 }) {
    success
    transactionHash
    errorCode
    error
  }
}
json
{ "data": { "cardWithdrawForPartner": { "success": true, "transactionHash": "0xabc…" } } }

Your smart wallets

When you provision a sub-client with createCardApplicationForPartnerUser(input: { createWallet: true }), Agio registers your Treasury wallet as that sub-client's collateral admin rather than minting a separate per-cardholder wallet. AgioCard_vw_partner_wallet lists the smart wallets scoped to your partner organization (including your Treasury), with the ids and on-chain token balances you need to drive Multisend. Results are automatically scoped to your partner organization.

List your wallets (AgioCard_vw_partner_wallet)

Omit the filter to list every smart wallet your org owns; add where: { id: { _eq: N } } for a single wallet (handy for subsequent lookups):

graphql
query OrgWallets {
  AgioCard_vw_partner_wallet {
    id # pass as sourceWalletId to multiSendFromWallet
    wallet_address
    chain_id # wallet home/registration chain (1 = Ethereum) — transfers run on the token's chain
    chain_name
    status # approved / pending / frozen …
    wallet_type
    total_balance_usd
    token_balances {
      token_chain_id # pass as tokenChainId on each multiSendFromWallet transfer
      token_symbol
      token_balance
      token_balance_usd
      token_address
      is_native
      decimals
      smart_wallet_trading_enabled # both true => eligible to send via multisend
      verified
    }
  }
}

Wallet fields:

FieldNotes
idThe wallet's Agio id — pass it as sourceWalletId to multiSendFromWallet.
wallet_addressThe on-chain EVM address of the smart wallet.
chain_id / chain_nameThe wallet's home/registration chain (Ethereum mainnet, 1). Transfers run on the token's chain, not this one — see the note below.
statusWallet status (approved, pending, frozen, …).
total_balance_usdRolled-up USD value across the wallet's tokens.

token_balances (nested array):

FieldNotes
token_chain_idThe Agio token-chain id — pass it as tokenChainId on a transfer.
token_symbol / token_nameThe asset.
token_balance / token_balance_usdOn-chain balance held at the wallet, and its USD value.
token_address / is_native / decimalsContract address (or native), and token decimals.
smart_wallet_trading_enabled + verifiedBoth true ⇒ the token is eligible to send via multisend. A token that fails either is rejected with TOKEN_CHAIN_NOT_ELIGIBLE.

Wallet home chain vs. transfer chain

chain_id is the smart wallet's home/registration network — Ethereum mainnet (1) today — not the chain your transfers execute on. The smart-wallet address is identical on every EVM chain, so one wallet holds and moves assets across chains. Multisend resolves the execution chain per token from token_balances[].token_chain_id: the eligible collateral token settles on Base (Base Sepolia on dev, Base on prod). Pick the tokenChainId for the chain you want to send on.

This is where multisend ids come from

multiSendFromWallet takes a numeric sourceWalletId and a tokenChainId per transfer — read id and token_balances[].token_chain_id from this query rather than hardcoding them. The token_balances are the same on-chain deposit balances as AgioCard_vw_card_token_balance, with the token-chain id and eligibility flags added.

Multisend

multiSendFromWallet moves funds from one source smart wallet to many destinations in a single atomic on-chain batch — one ERC-4337 UserOp, so every transfer lands together or none do. It's the partner-facing way to fan a treasury wallet out to many recipients: bulk payouts, disbursements, sweeping collateral, or bulk-funding many cards' collateral in one batch.

Unlike the card operations above, multisend is asynchronous. The mutation validates synchronously and enqueues a job — it does not wait for the on-chain broadcast. You get back a jobId; poll multiSendJobStatus until the job reaches a terminal state.

One wallet in, many out — all on one chain

Every transfer in a batch must resolve to the same chain; mixing chains is rejected (MIXED_CHAINS). The source is a smart wallet your organization controls (sourceWalletId); destinations are any EVM addresses.

Funding a card vs. multisend — opposite directions

Funding cards tops up a single card by sending stablecoin to its deposit address. Multisend moves funds out of a smart wallet to many destinations at once — and because those destinations can be card deposit addresses, multisend doubles as a way to bulk-fund many cards from a treasury. Everyday top-up = one direct deposit; multisend = the batch primitive.

Send a batch (multiSendFromWallet)

graphql
mutation MultiSend($input: MultiSendFromWalletInput!) {
  multiSendFromWallet(input: $input) {
    success
    jobId
    alreadyEnqueued
    errorCode
    errorMessage
    retryAfterSec
  }
}
json
{
  "input": {
    "sourceWalletId": 1234,
    "transfers": [
      { "tokenChainId": 42, "destination": "0x1111111111111111111111111111111111111111", "amount": "100.5" },
      { "tokenChainId": 42, "destination": "0x2222222222222222222222222222222222222222", "amount": "250" }
    ],
    "idempotencyKey": "payout-2026-06-18-batch-7"
  }
}

Input fields:

FieldTypeNotes
sourceWalletIdInt!Agio id of the smart wallet to send from — get it from AgioCard_vw_partner_wallet.id. Must belong to your partner organization.
transfers[MultiSendTransferInput!]!1–20 transfers (see Limits).
transfers[].tokenChainIdInt!Agio token-chain id for the asset + chain — read it from a wallet's token_balances[].token_chain_id. The asset must be smart-wallet-trading-enabled and verified.
transfers[].destinationString!Recipient EVM address (0x…). The zero address is rejected; the address is checksummed on the way in.
transfers[].amountString!Human-readable amount (e.g. "100.5"). Must be positive and within the token's decimals.
idempotencyKeyStringOptional, ≤128 chars, no whitespace. Omit to let Agio derive one (see Idempotency).

A successful response means enqueued, not executed:

json
{
  "data": {
    "multiSendFromWallet": { "success": true, "jobId": "payout-2026-06-18-batch-7", "alreadyEnqueued": null, "errorCode": null, "errorMessage": null, "retryAfterSec": null }
  }
}

success: true means the batch passed synchronous validation (auth, single chain, token eligibility, rate limit) and a job is queued — capture jobId and poll for the on-chain outcome. A success: false carries an errorCode; the job is not queued.

Bulk-fund cards from a treasury wallet

A card is collateralized simply by sending an approved token to its deposit address (Funding cards), and multisend sends to any EVM address — so a single batch aimed at your cards' deposit addresses funds many cards at once, straight from a treasury wallet. Each transfer is a standard on-chain token transfer; the card processor detects the deposit at that address and credits the collateral automatically, with no requirement on who sent it — funding from a treasury wallet works exactly like a manual deposit.

  1. Read each card's funding addressAgioCard_card_application.deposit_address + deposit_chain_id. Every card in one batch must be on the same chain (mixed chains are rejected with MIXED_CHAINS), so batch by chain.
  2. Pick the collateral token your treasury holds — a token_balances[].token_chain_id that is smart_wallet_trading_enabled && verified (see Your smart wallets) and accepted by the cards' card contract. An ineligible or unaccepted token lands on-chain but won't credit.
  3. Send the batch from your treasury (sourceWalletId), one transfer per card:
json
{
  "input": {
    "sourceWalletId": 1234,
    "transfers": [
      { "tokenChainId": 42, "destination": "<card A deposit_address>", "amount": "100" },
      { "tokenChainId": 42, "destination": "<card B deposit_address>", "amount": "250" }
    ],
    "idempotencyKey": "fund-cards-2026-06-19-batch-1"
  }
}

Once the job reaches completed, confirm each card's new collateral via AgioCard_vw_card_token_balance (on-chain) and AgioCard_vw_card_user_balance (credit/spending). Up to 20 cards per batch (Limits) — split larger card sets across batches.

Same wallet, both directions

A card's deposit_address (created with createWallet: true) is itself an org smart wallet you own — so one wallet can be a multisend source (sweeping funds out) and another wallet's multisend destination (funding a card in). Multisend doesn't distinguish "funding" from "payout"; it just moves an approved token to an address.

Idempotency & safe retries

Multisend is safe to retry — submitting the same batch twice never double-sends.

  • Provide your own idempotencyKey (recommended): any opaque string ≤128 chars with no whitespace. Reuse it verbatim when retrying a timed-out request.
  • Or omit it: Agio derives a deterministic key from (your organization, sourceWalletId, the normalized set of transfers). The same batch collapses to the same job even without a key — and because amounts are normalized to base units first, "100.5" and "100.50" are treated as identical.

On a duplicate submission you get alreadyEnqueued: true with the existing job's jobId — poll that id rather than treating it as a fresh send:

json
{ "data": { "multiSendFromWallet": { "success": true, "jobId": "payout-2026-06-18-batch-7", "alreadyEnqueued": true } } }

Retry with the same key — never re-key

On a timeout, retry with the same idempotencyKey. You'll either enqueue the job (first time the server saw it) or get alreadyEnqueued: true pointing at the in-flight job — both safe. Generating a fresh key on retry is the one way to double-send.

Poll job status (multiSendJobStatus)

graphql
query MultiSendStatus($jobId: String!) {
  multiSendJobStatus(jobId: $jobId) {
    success
    jobId
    status
    txHash
    failedReason
    attempts
    errorCode
    errorMessage
  }
}
json
{ "jobId": "payout-2026-06-18-batch-7" }

Jobs advance pendingrunningsubmittedcompleted, branching to failed or insufficient_balance on rejection:

statusMeaningYour move
pendingQueued, not yet picked up.Keep polling.
runningWorker is validating + checking balances.Keep polling.
submittedBroadcast to the chain; awaiting confirmation.Keep polling for completed + txHash.
completedOn-chain and confirmed. txHash is set.Done.
failedRejected before or at broadcast; see failedReason.Read failedReason, fix, resubmit as a new batch.
insufficient_balanceThe source wallet didn't hold enough for the batch.Top up the wallet, then resubmit.

A job stuck in submitted is indeterminate, not failed

If the broadcast outcome is unknown (e.g. a confirmation timeout), the job is left in submitted rather than flipped to failed — the UserOp may already be on-chain. Do not resubmit a submitted job; that risks a double-send. Reconcile against the wallet's on-chain history (or ask Agio) to confirm whether it landed.

Terminal-state examples:

json
{ "data": { "multiSendJobStatus": { "success": true, "jobId": "payout-2026-06-18-batch-7", "status": "completed", "txHash": "0xabc123…", "failedReason": null, "attempts": 1 } } }
json
{
  "data": {
    "multiSendJobStatus": {
      "success": true,
      "jobId": "payout-2026-06-18-batch-7",
      "status": "failed",
      "txHash": null,
      "failedReason": "batch USD 750000.00 exceeds cap 500000",
      "attempts": 1
    }
  }
}
json
{
  "data": {
    "multiSendJobStatus": {
      "success": true,
      "jobId": "payout-2026-06-18-batch-7",
      "status": "insufficient_balance",
      "txHash": null,
      "failedReason": "USDC: need 350500000, have 100000000 (wei)",
      "attempts": 1
    }
  }
}

failedReason is a human-readable diagnostic, not a stable code — surface it for debugging, don't branch on its exact text. attempts is how many times the worker has run the job (BullMQ retries transient errors automatically).

Polling guidance: poll every few seconds; most batches reach a terminal state within a few worker cycles.

Limits & controls

LimitDefaultNotes
Destinations per batch20transfers array length (minimum 1).
Max batch value$500,000Summed USD across the batch. Fails closed — if any token's USD price is unavailable, the job is rejected, not skipped.
Send rate5 / 10 minPer partner organization. Exceeding returns RATE_LIMITED with retryAfterSec.
Request body size512 KBThe partner endpoint caps the multisend request body.

These are server-side defaults, tunable per environment — treat the numbers as the current production configuration, not a hard contract.

Beyond the size limits, every batch is checked for single chain (all tokenChainIds resolve to one chain), token eligibility (each asset is smart-wallet-trading-enabled and verified), and sufficient balance (pre-broadcast, against live on-chain balances).

Error codes

multiSendFromWallet returns these synchronously in errorCode — when one is present the job is not enqueued.

errorCodeOperationCause
FORBIDDENbothCaller is not a partner organization. On multiSendFromWallet, also: sourceWalletId not found or not owned by your org (generic — does not reveal existence).
VALIDATION_ERRORmultiSendFromWalletInput failed validation — bad address, non-positive amount, amount exceeding the token's decimals, empty or over-20 transfers, or an unknown tokenChainId.
MIXED_CHAINSmultiSendFromWalletThe transfers resolve to more than one chain. Split into one batch per chain.
TOKEN_CHAIN_NOT_ELIGIBLEmultiSendFromWalletA tokenChainId is not enabled for smart-wallet trading (or not verified).
RATE_LIMITEDmultiSendFromWalletOrg send-rate exceeded. Back off for retryAfterSec, then retry.
NOT_FOUNDmultiSendJobStatusNo job with that jobId under your organization (also returned for another org's job — existence-hidden).
INTERNAL_ERRORbothUnexpected server-side error. Re-poll by jobId before resubmitting a send.

Failures after a job is enqueued don't appear here — they surface as multiSendJobStatus.status = failed (or insufficient_balance) with a human-readable failedReason. See the status lifecycle above.

Full type definitions are in the Partner API Reference.

Response envelope

Card* mutations share a predictable envelope: every response has a success: Boolean! field and an optional error: String with the human-readable failure reason. Resource-specific fields (e.g. organizationId, cardId, chargeId) appear on success.

graphql
type CardOperationResponse {
  success: Boolean!
  id: Int # our internal card ID
  cardId: String # external card ID
  status: String
  error: String
}

Hasura passthrough queries (e.g. AgioCard_card, AgioCard_card_user) return arrays of typed rows directly — no envelope. Field shapes are enforced by the GraphQL schema itself; use the Partner API Reference for the authoritative type definitions of every operation.

replaceCard / replaceVirtualCard

replaceVirtualCard(cardId: Int!) is a one-arg shorthand for virtual cards. replaceCard(input: ReplaceCardInput!) takes the full envelope:

graphql
mutation {
  replaceCard(input: { cardId: 42, reason: lost }) {
    success
    id
    oldCardId
    newCard {
      cardId
      last4
      expirationMonth
      expirationYear
    }
    error
  }
}

reason is a CardReplacementReason enum — one of lost, stolen, damaged. shippingAddress is required for physical replacements and ignored for virtual.

Both replacement mutations return a CardReplacementResponse envelope that doesn't match the cardId-on-top shape of the standard CardOperationResponse. Persist id (Agio internal id of the new card) and oldCardId (the just-cancelled external id) — the new external id is on newCard.cardId:

graphql
type CardReplacementResponse {
  success: Boolean!
  id: Int
  oldCardId: String
  newCard: CardReplacedCard
  error: String
}

CardLimitFrequency enum values

updateCardLimit requires one of: per24HourPeriod, per7DayPeriod, per30DayPeriod, perYearPeriod, allTime. There is no daily / monthly shorthand.

chargeCard example

graphql
mutation {
  chargeCard(input: { cardUserId: "external-card-user-uuid", feeCents: 2550, feeDescription: "Monthly service fee" }) {
    success
    chargeId
    error
  }
}

Fails with Not Authorized (extensions.code: "FORBIDDEN") if the cardUserId is not yet provisioned in your partner organization, or belongs to a different partner — the message is intentionally generic and does not reveal existence. Provision the card user via your normal onboarding flow first.

Example Queries

Check a card's balance (Card query)

graphql
query {
  cardBalance(cardId: 42) {
    success
    balance {
      creditLimit
      spendingPower
      balanceDue
    }
  }
}

List cards via Hasura

graphql
{
  AgioCard_vw_card {
    id
    type
    status
    last4
    expiration_month
    expiration_year
    limit_frequency
  }
}

Client card info in one call

AgioCard_vw_card carries relationships, so one nested query returns everything you'd show a cardholder — card details, the collateral / deposit address (via card_application), and credit / spending (via balance) — no client-side joins:

graphql
query ClientCardInfo($cardUserId: uuid!) {
  AgioCard_vw_card(where: { card_user_id: { _eq: $cardUserId } }) {
    last4
    status
    type
    expiration_month
    expiration_year
    limit_amount
    limit_frequency
    card_application {
      deposit_address # where the client funds the card
      deposit_chain_id # on which chain
      application_status
    }
    balance {
      credit_limit
      collateral_balance
      spending_power
      balance_due
    }
  }
}

AgioCard_card_user exposes the same from the cardholder side — it nests balance, card_applications, cards, and transactions. For the live on-chain token balance at the deposit address (vs. the rolled-up collateral_balance), read AgioCard_vw_card_token_balance keyed by deposit_address.

Freeze a card

graphql
mutation {
  freezeCard(cardId: 42) {
    success
    status
    error
  }
}

Cardholders for your organization

graphql
{
  AgioCard_card_user {
    id
    card_user_id
    application_status
    is_active
    wallet_address
  }
}

Monthly spend per user

graphql
{
  AgioCard_vw_card_user_monthly_spend {
    card_user_id
    month
    amount_cents
  }
}

Error Reference

Transport-level errors (HTTP body, no GraphQL envelope):

HTTPBodyCause
401{"error":"Unauthorized", ...}Missing/invalid API key, bad signature, expired/revoked token, or timestamp out of window
503upstream-unavailableNonce store (Redis) or Hasura temporarily unavailable

GraphQL-level errors (errors[].extensions.code):

CodeCause
GRAPHQL_PARSE_FAILEDSyntax error in query
GRAPHQL_VALIDATION_FAILEDQuery references a type outside the partner scope
FORBIDDENCard/resource belongs to a different partner organization (intentionally generic — does NOT reveal existence)
UPSTREAM_HASURA_ERRORHasura returned an unexpected error
CARD_CONFIG_ERRORCard service not configured on the server side (operational issue, contact account manager)
CARD_API_ERRORUpstream card processor returned an error — message contains the underlying reason
CARD_NOT_FOUNDCard ID does not exist or is not visible to your partner organization
CARD_USER_NOT_FOUNDReturned ONLY from createCard when the card_application's cardUserId hasn't propagated yet (webhook race — partner already owns the application). Cross-tenant lookup failures return generic FORBIDDEN instead and never reveal existence.
CARD_INVALID_TYPEcardType value not supported for this operation
CARD_INVALID_REQUESTInput payload failed validation (see error field for details)
CARD_UNAUTHORIZEDCard processor rejected the operation (e.g. policy or status mismatch)
CARD_FORBIDDENOperation not permitted in the card's current state (e.g. cancelled card)
CARD_RATE_LIMITEDCard processor rate-limit hit — retry with exponential backoff
CARD_SERVICE_UNAVAILABLECard processor temporarily unreachable
CARD_TIMEOUTCard processor took too long to respond — safe to retry after a short delay
CARD_NETWORK_ERRORTransient network error between Agio and the card processor — safe to retry after a short delay
KYC_TOKEN_EXPIREDpartnerKycShareToken was expired, replayed, or otherwise rejected — generate a fresh token and retry
KYC_WORKSPACE_NOT_AUTHORIZEDYour KYC provider workspace is not registered with Agio — contact your account manager to start onboarding
KYC_PROVIDER_NOT_SUPPORTEDThe supplied partnerKycShareToken came from a KYC provider we don't route to. Only Sumsub is currently supported

Webhook subscription mutation codes (returned in errorCode field — separate from GraphQL errors):

errorCodeOn mutationMeaning
FORBIDDENsubscribe / unsubscribe / rotateCaller is not a partner organization
NOT_FOUNDunsubscribe / rotateSubscription doesn't exist or belongs to another organization
INSERT_FAILEDsubscribeDB / KMS / validation error during persistence — safe to retry
UPDATE_FAILEDunsubscribe / rotateDB error during update — safe to retry
ROTATION_RACErotateConcurrent rotation completed first; re-fetch and decide whether to retry

Multisend codes (multiSendFromWallet / multiSendJobStatus) are listed in the Multisend error codes section above.

Example error responses

401 Unauthorized — missing API key:

json
{ "error": "Unauthorized", "description": "Invalid API key" }

401 Unauthorized — HMAC signature mismatch:

json
{ "error": "Unauthorized", "description": "Invalid signature" }

401 Unauthorized — timestamp outside ±5 min window (usually clock skew):

json
{ "error": "Unauthorized", "description": "Timestamp outside allowed window" }

403 FORBIDDEN — cross-partner access attempt (you tried to act on a card or card_user that belongs to a different partner's organization):

json
{
  "data": null,
  "errors": [
    {
      "message": "Not Authorized",
      "extensions": { "code": "FORBIDDEN" }
    }
  ]
}

The error message is intentionally generic — it does NOT reveal whether the resource exists. Do not rely on the message to distinguish "not found" from "exists but forbidden"; both paths return the same shape.

400 GRAPHQL_VALIDATION_FAILED — query references a type outside the partner scope (e.g. attempting to query AgioAuth_user which is not in the stitched schema):

json
{
  "errors": [
    {
      "message": "Cannot query field \"AgioAuth_user\" on type \"Query\".",
      "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" }
    }
  ]
}

Best practices

Secret storage. Store client_secret in your secret manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Never commit it to source control, never log it, never send it over unencrypted channels, never include it in URL query strings. The api_token UUID is less sensitive (it only identifies your partner) but should be treated as non-public.

Retry on 503. A 503 response means the nonce store (Redis) or upstream Hasura is transiently unavailable. Retry with exponential backoff starting at 1s, capped at 32s, up to 5 attempts. After 5 failures, alert your on-call — don't silently drop the request.

Retry on 401 is almost always wrong. A 401 means the signature, timestamp, or API key is invalid. Retrying with the same payload will replay the same signature — which the nonce store will reject. If you're seeing intermittent 401s, check (a) clock skew vs. server time, (b) timestamp granularity (must be milliseconds, not seconds), (c) request body serialization stability (the JSON string you sign must match the JSON string you send byte-for-byte).

Idempotency. Most mutations are NOT idempotent — if a network timeout drops the response, check the resource state via a query before retrying (e.g. query AgioCard_card_application after a createCardApplicationForPartnerUser that timed out). Exceptions:

MutationIdempotency keyBehavior on re-run
createPartnerCardUser(customerOrganizationId, email)Returns existing cardUserId with reused: true
createCardApplicationForPartnerUsercardUserId (one-app-per-user gate)Returns CARD_INVALID_REQUEST if an app already exists
subscribePartnerWebhooknone — always creates a new subscriptionMultiple subs to the same URL coexist; unsubscribe by ID

Credential rotation. Contact your Agio account manager to rotate credentials. Old credentials remain valid until explicitly revoked — there is no automatic expiry. We recommend rotating at least every 12 months, or immediately on any suspected compromise, staff departure, or accidental log exposure. Plan the rotation window: the cutover is instant (the new token is active immediately; the old token can be revoked in the same operation).

Changelog

  • Breaking: the cardWithdrawForPartner in-flight-signing error code was renamed to CARD_SIGNING_IN_FLIGHT. If you previously matched the legacy error-code string, update it accordingly.
Partner Card API has loaded