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.

Endpoint: https://api.agiodigital.com/partner/cards/graphql

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.

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).

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

Node.js / TypeScript

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

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 / curl

For quick manual testing or shell scripts:

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).

Schema Overview

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

SourcePrefixOperationsDescription
HasuraAgioCard_*36 queriesRead card data, users, balances, transactions
Platform APICard* / utility names1 query + 23 mutationsOnboarding, card lifecycle, operations, funding

All results are automatically scoped to your partner organization.

Available Mutations

Onboarding:createPartnerCustomerOrganization · createCardApplicationForPartnerUser

Encryption session (PIN / reveal handshake):generateEncryptionKeys

Card lifecycle:createCard · replaceCard · replaceVirtualCard · cancelCard

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

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

Funding:chargeCard

Not exposed via this endpoint

  • createCardApplication — use createCardApplicationForPartnerUser instead (partner-specific resolver that skips Agio-user KYC)
  • createCardCorporateApplication — deferred; the underlying resolver requires an authenticated Agio user in context. A partner-aware corporate onboarding flow will ship in a later phase. Contact your Agio account manager if you need corporate applications provisioned in the interim (Agio ops can create them on your behalf).
  • cardWithdraw — planned for a future release (depends on cardWithdraw refactor)
  • 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".

Onboarding Flow

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. Use organizationId when coordinating with Agio ops for corporate application provisioning (currently a manual step — see Not exposed via this endpoint).

Individual card application (partner-provisioned user)

graphql
mutation {
  createCardApplicationForPartnerUser(
    input: {
      cardUserId: "external-card-user-uuid"
      walletAddress: "0x1234...abcd"
      occupation: "Software Developers"
      annualSalary: "100000"
      accountPurpose: "Business expenses"
      expectedMonthlyVolume: "5000"
      isTermsOfServiceAccepted: true
    }
  ) {
    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.

cardUserId is the external user id Agio ops returns during onboarding — it's the same value Hasura exposes on AgioCard_card_user.card_user_id. 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.

Preconditions on cardUserId

The cardUserId Agio ops hands you is not just a database id — it's the join key for three rows that must exist before you call this mutation:

  1. AgioCard_card_user — links the user to a card_company under one of your partner-managed customer orgs (Agio ops creates this).
  2. KycData_kyc_profile with sumsub_applicant_id = cardUserId and a complete questionnaire_data payload (date of birth, national id), plus mailing address, country of residence, and phone. The Card API uses these fields to satisfy the issuer's KYC payload schema.
  3. A Sumsub sandbox/production applicant with the same id, if the issuer-side Sumsub share-token flow is enabled in your environment (RAIN_SUMSUB_CLIENT_ID configured). Without a matching applicant, share-token generation fails and the call falls back to a direct-PII payload that requires the kyc_profile fields above.

If any of these are missing you'll see CARD_API_ERROR: body must have required property 'sumsubShareToken', 'birthDate', 'key', ... from the issuer. Contact your Agio account manager — they cannot be self-served via the partner API.

Partner cards have nullable AgioCard_card.user_id

Cards created via createCard from a partner-flow application persist with user_id = NULL (no Agio user behind them) and card_company_id populated instead. The database CHECK card_user_or_company_present enforces user_id IS NOT NULL OR card_company_id IS NOT NULL, so either anchor is sufficient. Don't rely on user_id joins when scoping partner card data — use card_company_id → card_company.organization_id and filter against your partner_managed_organization rows.

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).

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 User not found if the cardUserId is not yet provisioned in the issuer's account ledger — fund 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
  }
}

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

HTTPextensions.codeCause
401Missing/invalid API key, bad signature, expired/revoked token, or timestamp out of window
503Nonce store (Redis) or Hasura temporarily unavailable
400GRAPHQL_PARSE_FAILEDSyntax error in query
400GRAPHQL_VALIDATION_FAILEDQuery references a type outside the partner scope
403FORBIDDENCard/resource belongs to a different partner organization
502UPSTREAM_HASURA_ERRORHasura returned an unexpected error

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. Mutations are NOT idempotent. If you don't receive a response (network timeout, connection drop), check the resource state via a query before retrying — e.g. query AgioCard_card_application after a createCardApplicationForPartnerUser that timed out.

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).

Partner Card API has loaded