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_* | 34 queries | Read card data, users, balances, transactions |
| Platform API | Card* / utility names | 1 query + 22 mutations | Onboarding, card lifecycle, operations, funding |
All results are automatically scoped to your partner organization.
Available Mutations
Onboarding:createPartnerCustomerOrganization · createCardApplicationForPartnerUser
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: "rain-user-uuid"
walletAddress: "0x1234...abcd"
occupation: "Software Developer"
annualSalary: "100000"
accountPurpose: "Business expenses"
expectedMonthlyVolume: "5000"
isTermsOfServiceAccepted: true
}
) {
success
cardApplicationId
applicationStatus
}
}PIN encryption (setCardPin / getCardPin)
PIN values transit a per-session encrypted channel. The handshake derives a transfer key from your partner organization id — the UUID returned by createPartnerCustomerOrganization for your home tenant, or the partner_organization_id field exposed by your token row. Use it as the passphrase identity when calling encryptPassphraseForTransfer from agio-utils:
import { encryptPassphraseForTransfer } from "agio-utils";
const encryptedPin = await encryptPassphraseForTransfer({
passphrase: "1234",
sessionId, // returned by your earlier session-init call
identity: PARTNER_ORGANIZATION_ID // NOT a card user id, NOT the api token
});
await partnerQuery(
`mutation SetPin($input: SetCardPinInput!) {
setCardPin(input: $input) { success error }
}`,
{ input: { cardId, sessionId, encryptedPin } }
);Identity matches the server-side decryption key
The Platform API decrypts using the same PARTNER_ORGANIZATION_ID it pulls from your token row. Encrypting under any other identity (card user UUID, API token UUID) will decrypt to garbage and the PIN validator will reject it with a generic 400.
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.
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).