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:
| Environment | URL |
|---|---|
| Production | https://api.agiodigital.com/partner/cards/graphql |
| Development | https://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
unzip webhook-starter-kit.zip && cd webhook-starter-kit
cp .env.example .env # fill in your credentials
bun install && bun receiveSee 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.
- Authenticate every request with
x-agio-api-key+ an HMAC signature. → Authentication · Signing Requests - Provision a customer org, then a cardholder:
createPartnerCustomerOrganization→createPartnerCardUser. → Onboarding Flow - Create the application with a wallet:
createCardApplicationForPartnerUser(input: { createWallet: true, … }), pollAgioCard_card_applicationuntilAPPROVED/ACTIVE, thencreateCard. → Onboarding Flow - Fund the card: read its
deposit_addressand send the stablecoin there. Dev: rUSD on Base Sepolia (84532). Prod: USDC on Base (8453). → Funding cards - Confirm it landed:
AgioCard_vw_card_token_balance(on-chain) +cardBalance(credit/spending). → Funding cards
The two things that trip people up
- Send funds to the card's
deposit_address(fromAgioCard_card_application), not the cardholder's personal wallet. This is the #1 mistake. - Pass
createWallet: truewhen creating the application, or it has nodeposit_addressto 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_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
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):
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();
}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())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).
If signing fails with 401
The two most common causes (in order) are:
- Timestamp in seconds, not milliseconds — the server expects
Date.now()(e.g.1744736599000).date +%son macOS gives seconds; usenode -e 'process.stdout.write(Date.now().toString())'orpython3 -c 'import time; print(int(time.time()*1000))'. - 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
bodystring to both.
See Best practices for the full list.
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 + 25 mutations | Onboarding, 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— 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".
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.
| Tier | Window | Limit | Applies to |
|---|---|---|---|
| Standard | 1 min | 50 req | Most lifecycle ops: freezeCard, unfreezeCard, cancelCard, replaceCard, updateCardLimit, … |
| Strict | 1 min | 5 req | Sensitive ops: setCardPin, getCardPin, revealCardSecrets, generateEncryptionKeys |
| Address | 1 min | 20 req | autocompleteAddress, 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
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
mutation {
createPartnerCardUser(
input: {
customerOrganizationId: "<organizationId from createPartnerCustomerOrganization>"
firstName: "Alice"
lastName: "Tester"
email: "[email protected]"
addressLine1: "1 Main St"
addressCity: "Brooklyn"
addressRegion: "NY"
addressPostalCode: "11201"
addressCountryCode: "US"
phoneCountryCode: "1"
phoneNumber: "5551234567"
birthDate: "1990-01-15"
nationalId: "123-45-6789"
}
) {
success
cardUserId
reused
errorCode
errorMessage
}
}Persist the returned cardUserId (UUID). It's stable across all future operations on this cardholder — pass it as cardUserId on subsequent createCardApplicationForPartnerUser calls.
Idempotency
createPartnerCardUser is idempotent on (customerOrganizationId, email). Re-running with the same email under the same customer org returns the existing cardUserId with reused: true — no duplicate row created.
nationalId is encrypted at rest
The nationalId you provide is encrypted on the way into storage and decrypted only at request-time when we forward it to the card processor. Plaintext never lands on disk.
Individual card application (partner-provisioned user)
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 returnsKYC_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 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.
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 onCardApplicationResponse.walletAddress; you can also read it later fromAgioCard_card_application.wallet_address(via Hasura) — the same address appears on every subsequent application under the same customer org. - Validation.
walletAddressandcreateWalletare mutually exclusive. Supplying both — or neither — returnsVALIDATION_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 (PENDING → IN_REVIEW → APPROVED → ACTIVE) 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.
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 returnsCARD_RATE_LIMITEDwith aRetry-Afterheader. - Cursor: Pass the last
updated_atyou observed as thesincevariable 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:
APPROVEDandACTIVEare go-states forcreateCard.REJECTED/CANCELLEDare 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 viarotatePartnerWebhookSecret(24h grace).
Do not reuse one for the other.
Subscribed events
| Event name | Fires on |
|---|---|
card_application.status_changed | card_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
mutation Subscribe($input: SubscribePartnerWebhookInput!) {
subscribePartnerWebhook(input: $input) {
success
subscriptionId
signingSecret # returned ONCE — capture immediately, never readable again
errorCode
errorMessage
}
}{ "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:
errorCode | Meaning |
|---|---|
| null | Success — capture signingSecret immediately |
FORBIDDEN | Caller is not a partner organization |
INSERT_FAILED | Persistence 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:
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);
}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." }
}eventIdis prefixedtest_so your dedup table can distinguish from realcas_…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
eventIdis stable across retries —sha256(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 thensubscribePartnerWebhookagain (new subscription, new signing secret). The old subscription stays inpartner_webhook_subscriptionwithis_active = falsefor audit. Counter resets to 0 on any 2xx delivery — partial outages auto-heal without the threshold tripping. - Failure classification: the
last_failure_reasoncolumn on the subscription captures the cause:http_<status>for issuer responses, an Error message for fetch/timeout failures, orssrf:<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
mutation {
rotatePartnerWebhookSecret(subscriptionId: "<id>") {
success
signingSecret
previousSecretValidUntil
errorCode
errorMessage
}
}Rotate result codes:
errorCode | Meaning |
|---|---|
| null | Success — capture new signingSecret; previous remains valid until previousSecretValidUntil (ISO 8601, +24h) |
FORBIDDEN | Caller is not a partner organization |
NOT_FOUND | Subscription doesn't exist or belongs to another organization |
ROTATION_RACE | A concurrent rotation completed first. Re-fetch the subscription (partner_webhook_subscription_by_pk) and decide whether to retry |
UPDATE_FAILED | DB 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)
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
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
mutation {
unsubscribePartnerWebhook(subscriptionId: "<id>") {
success
errorCode
errorMessage
}
}Unsubscribe result codes:
errorCode | Meaning |
|---|---|
| null | Success |
FORBIDDEN | Caller is not a partner organization |
NOT_FOUND | Subscription doesn't exist or belongs to another organization |
UPDATE_FAILED | DB 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_subscriptionqueries withis_active = falsefor 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:
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:
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:
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:
query CardUserBalances {
AgioCard_vw_card_user_balance {
card_user_id
credit_limit
collateral_balance
spending_power
balance_due
}
}Sample fund flow
- Read the application's
deposit_address+deposit_chain_id(above) — created withcreateWallet: true - Send the supported stablecoin (USDC on Base mainnet · rUSD on Base Sepolia for dev) to that address
- Wait for the on-chain confirmation
AgioCard_vw_card_token_balance.token_balancereflects the deposit;cardBalance(cardId)andAgioCard_vw_card_user_balance.spending_powerreflect 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.
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 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)
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
Transport-level errors (HTTP body, no GraphQL envelope):
| HTTP | Body | Cause |
|---|---|---|
| 401 | {"error":"Unauthorized", ...} | Missing/invalid API key, bad signature, expired/revoked token, or timestamp out of window |
| 503 | upstream-unavailable | Nonce store (Redis) or Hasura temporarily unavailable |
GraphQL-level errors (errors[].extensions.code):
| Code | Cause |
|---|---|
GRAPHQL_PARSE_FAILED | Syntax error in query |
GRAPHQL_VALIDATION_FAILED | Query references a type outside the partner scope |
FORBIDDEN | Card/resource belongs to a different partner organization (intentionally generic — does NOT reveal existence) |
UPSTREAM_HASURA_ERROR | Hasura returned an unexpected error |
CARD_CONFIG_ERROR | Card service not configured on the server side (operational issue, contact account manager) |
CARD_API_ERROR | Upstream card processor returned an error — message contains the underlying reason |
CARD_NOT_FOUND | Card ID does not exist or is not visible to your partner organization |
CARD_USER_NOT_FOUND | Returned 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_TYPE | cardType value not supported for this operation |
CARD_INVALID_REQUEST | Input payload failed validation (see error field for details) |
CARD_UNAUTHORIZED | Card processor rejected the operation (e.g. policy or status mismatch) |
CARD_FORBIDDEN | Operation not permitted in the card's current state (e.g. cancelled card) |
CARD_RATE_LIMITED | Card processor rate-limit hit — retry with exponential backoff |
CARD_SERVICE_UNAVAILABLE | Card processor temporarily unreachable |
CARD_TIMEOUT | Card processor took too long to respond — safe to retry after a short delay |
CARD_NETWORK_ERROR | Transient network error between Agio and the card processor — safe to retry after a short delay |
KYC_TOKEN_EXPIRED | partnerKycShareToken was expired, replayed, or otherwise rejected — generate a fresh token and retry |
KYC_WORKSPACE_NOT_AUTHORIZED | Your KYC provider workspace is not registered with Agio — contact your account manager to start onboarding |
KYC_PROVIDER_NOT_SUPPORTED | The 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):
errorCode | On mutation | Meaning |
|---|---|---|
FORBIDDEN | subscribe / unsubscribe / rotate | Caller is not a partner organization |
NOT_FOUND | unsubscribe / rotate | Subscription doesn't exist or belongs to another organization |
INSERT_FAILED | subscribe | DB / KMS / validation error during persistence — safe to retry |
UPDATE_FAILED | unsubscribe / rotate | DB error during update — safe to retry |
ROTATION_RACE | rotate | Concurrent rotation completed first; re-fetch and decide whether to retry |
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. 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:
| Mutation | Idempotency key | Behavior on re-run |
|---|---|---|
createPartnerCardUser | (customerOrganizationId, email) | Returns existing cardUserId with reused: true |
createCardApplicationForPartnerUser | cardUserId (one-app-per-user gate) | Returns CARD_INVALID_REQUEST if an app already exists |
subscribePartnerWebhook | none — always creates a new subscription | Multiple 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).