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 and send the stablecoin there. Dev: rUSD on Base Sepolia (84532). 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).

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

Sign each execution request with HMAC-SHA256 over `${timestamp}.${rawBody}`, 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_*36 queriesRead card data, users, balances, transactions
Platform APICard* / utility names1 query + 25 mutationsOnboarding, card lifecycle, operations, fees

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 will be marked @deprecated in the next API revision — use freezeCard / unfreezeCard in new code.

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

Funding:chargeCard

Webhook subscriptions:subscribePartnerWebhook · unsubscribePartnerWebhook · rotatePartnerWebhookSecret

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

Rate Limits

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

TierWindowLimitApplies to
Standard1 min50 reqMost lifecycle ops: freezeCard, unfreezeCard, cancelCard, replaceCard, updateCardLimit, …
Strict1 min5 reqSensitive ops: setCardPin, getCardPin, revealCardSecrets, generateEncryptionKeys
Address1 min20 reqautocompleteAddress, resolvePlaceAddress

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: "alice@example.com"
      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 provisions an org-scoped smart wallet (Base) — reused per customer org.
      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...) your customer controls — the same address that will hold the funding stablecoin and become the on-chain owner of the card contract.

If your customer doesn't have one, pass createWallet: true instead and Agio provisions an org-scoped smart wallet on Base mainnet (see the tip below). The same wallet is reused for every subsequent card under the same customer organization.

createWallet: true — let Agio provision the wallet

Pass createWallet: true in place of walletAddress and Agio handles wallet provisioning end-to-end:

  • Reused per customer org. The first card under a customer organization triggers provisioning; every subsequent card under the same org receives the same wallet address.
  • Chain. Base mainnet (chainId: 8453) — the chain that anchors the card contract today.
  • Address determinism + re-query. The provisioned address is deterministic per (customer org, chain). The mutation echoes it on CardApplicationResponse.walletAddress; you can also read it later from AgioCard_card_application.wallet_address (via Hasura) — the same address appears on every subsequent application under the same customer org.
  • 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)

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 not delivered via webhook today — track spend by polling AgioCard_card_transaction with the same updated_at cursor pattern used for application-status polling above. 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"] } }

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 (RFC1918 / loopback 127.0.0.0/8 / link-local 169.254.0.0/16 / CGNAT 100.64.0.0/10 / IPv6 private / cloud-metadata), plus any hostname that DNS-resolves to a private IP (rebinding defense). 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"
  }
}

The User-Agent is stable — safe to allowlist in WAF rules or filter in receiver logs. Agio aborts the request after 10s (connect 2s, total 10s); 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 from real cas_… traffic.
  • 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, drains pending retry jobs, and notifies your account contact. 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

Card funding is not a partner-API mutation. To add spending power to a card, send a supported stablecoin on-chain to the card's deposit address. 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.

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 # 8453 = Base mainnet (prod) · 84532 = Base Sepolia (dev)
    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 are provisioned on Base Sepolia (84532); on prod, Base mainnet (8453).

Supported stablecoin

Send USDC on prod (Base mainnet). On dev (Base Sepolia) the test collateral token is rUSD — mint it from the rUSD sandbox faucet and send it to the deposit address. 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 · rUSD on dev/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
  }
}

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 · rUSD on Base Sepolia for dev) 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.

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
  }
}

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 yet route to. Currently only Sumsub is supported; Persona support is on the roadmap

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

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

Partner Card API has loaded