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_tokenandclient_secretto 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}`:
| Header | Value |
|---|---|
x-agio-timestamp | Current Unix time in milliseconds (Date.now()) |
x-agio-signature | HMAC-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
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 / curl
For quick manual testing or shell scripts:
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:
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:
{
"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:
| Source | Prefix | Operations | Description |
|---|---|---|---|
| Hasura | AgioCard_* | 36 queries | Read card data, users, balances, transactions |
| Platform API | Card* / utility names | 1 query + 23 mutations | Onboarding, 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— usecreateCardApplicationForPartnerUserinstead (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 oncardWithdrawrefactor)payInvoiceWithCardBalance— Agio-internal billing flow, not relevant to partner integrations- All
*ByCardId/*ByCardUserIdadmin-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
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)
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 fromAgioCard_card_application.id. Pass this directly tocreateCard(input: { cardApplicationId, ... })once the application isAPPROVEDorACTIVE.cardApplicationExternalId(String) — issuer-side application UUID. Persist this for cross-referencing with the issuer's records and for joining againstAgioCard_card_application.card_application_external_idin 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:
AgioCard_card_user— links the user to acard_companyunder one of your partner-managed customer orgs (Agio ops creates this).KycData_kyc_profilewithsumsub_applicant_id = cardUserIdand a completequestionnaire_datapayload (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.- 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_IDconfigured). 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:
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.
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:
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:
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
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)
query {
cardBalance(cardId: 42) {
success
balance {
creditLimit
spendingPower
balanceDue
}
}
}List cards via Hasura
{
AgioCard_vw_card {
id
type
status
last4
expiration_month
expiration_year
limit_frequency
}
}Freeze a card
mutation {
freezeCard(cardId: 42) {
success
status
error
}
}Cardholders for your organization
{
AgioCard_card_user {
id
card_user_id
application_status
is_active
wallet_address
}
}Monthly spend per user
{
AgioCard_vw_card_user_monthly_spend {
card_user_id
month
amount_cents
}
}Error Reference
| HTTP | extensions.code | Cause |
|---|---|---|
| 401 | — | Missing/invalid API key, bad signature, expired/revoked token, or timestamp out of window |
| 503 | — | Nonce store (Redis) or Hasura temporarily unavailable |
| 400 | GRAPHQL_PARSE_FAILED | Syntax error in query |
| 400 | GRAPHQL_VALIDATION_FAILED | Query references a type outside the partner scope |
| 403 | FORBIDDEN | Card/resource belongs to a different partner organization |
| 502 | UPSTREAM_HASURA_ERROR | Hasura returned an unexpected error |
Example error responses
401 Unauthorized — missing API key:
{ "error": "Unauthorized", "description": "Invalid API key" }401 Unauthorized — HMAC signature mismatch:
{ "error": "Unauthorized", "description": "Invalid signature" }401 Unauthorized — timestamp outside ±5 min window (usually clock skew):
{ "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):
{
"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):
{
"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).