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_address+deposit_chain_idand send the matching stablecoin there. Dev: testnets — Eth Sepolia (11155111) / Base Sepolia (84532), e.g. rUSD on Base Sepolia. 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).
Your API credentials
Agio provisions two distinct values for you during onboarding — one identifies you, the other signs your requests:
| Credential | Header | Role |
|---|---|---|
api_token | x-agio-api-key | UUID that identifies your partner organization. |
client_secret | (signs x-agio-signature) | agc_-prefixed key used to HMAC-sign each request — see Signing Requests. |
The client_secret plaintext is shown once, at issuance, then stored only KMS-encrypted — Agio cannot read it back from any API or admin surface. If you lose it, ask your Agio account manager to rotate it; you'll receive a new client_secret (also shown once) and the old one stops working.
Two organization IDs, do not confuse them
Partner integrations involve two different organization IDs with opposite semantics:
partnerOrganizationIdis your own organization's identity. You never send it. The API derives it from your API key on every request; it cannot be passed or overridden by the client, and it appears only in responses. All your data is automatically scoped to it.customerOrganizationIdis returned bycreatePartnerCustomerOrganization. This is the only organization ID you supply: you pass it tocreatePartnerCardUserand other customer-scoped calls.
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
Using the Layer 2 — HMAC Signature mechanic above (HMAC-SHA256 hex digest over `${timestamp}.${rawBody}`, timestamp in ms), sign each execution request, 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_* | full read surface (dynamic) | Read card data, users, balances, transactions, wallets |
| Platform API | Card* / utility names | curated allowlist (card lifecycle, ops, fees) | Onboarding, card lifecycle, operations, fees |
The AgioCard_* read surface is exposed dynamically via Hasura introspection filtering — every partner-visible card table yields its own root fields, so there is no fixed operation count. The Platform-API side is a curated allowlist (card lifecycle, funding, address validation, webhook management) — currently ~34 operations (1 query, cardBalance, + 33 mutations).
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 are deprecated aliases — use freezeCard / unfreezeCard in new code.
Profile & address:updateCardUserProfile · updateCardCompanyAddress · validateAddress · validateCardShippingAddress · autocompleteAddress · resolvePlaceAddress
Funding:chargeCard
Smart-wallet treasury:multiSendFromWallet · companion query multiSendJobStatus
Webhook subscriptions:subscribePartnerWebhook · unsubscribePartnerWebhook · rotatePartnerWebhookSecret
Not exposed via this endpoint
createCardApplication— usecreateCardApplicationForPartnerUserinstead (partner-specific resolver that skips Agio-user KYC)createCardCorporateApplication— not currently supported for partners; the underlying resolver requires an authenticated Agio user in context. Contact your Agio account manager if you need corporate applications provisioned (Agio ops can create them on your behalf).cardWithdraw— not currently exposed to partners (partner withdrawal runs throughcardWithdrawForPartner)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
Rate limits are enforced per identity (partner key + client IP). Crossing a limit returns extensions.code === "RATE_LIMIT_EXCEEDED" with a Retry-After hint — back off and retry.
Layer 1 — Global endpoint cap. A hard ceiling of ~2000 requests/minute per identity applies to every operation, regardless of the per-operation limits below.
Layer 2 — Per-operation limits. On top of the global cap, individual operations carry the limits below:
| Limit | Window | Operations |
|---|---|---|
| 50 req | 1 min | Card lifecycle: freezeCard, unfreezeCard, lockCard, unlockCard, cancelCard, createCard, replaceCard, replaceVirtualCard, updateCardNickname, updateCardCompanyAddress, createPartnerCardUser, cardWithdrawForPartner, cardFundSubClientFromTreasury |
| 5 req | 1 min | Sensitive: revealCardSecrets, setCardPin, getCardPin, updateCardUserProfile, generateEncryptionKeys |
| 20 req | 1 min | Address autocomplete: autocompleteAddress, resolvePlaceAddress |
| 10 req | 1 min | Address validation: validateAddress, validateCardShippingAddress |
| 5 req | 10 min (per org, sliding) | multiSendFromWallet — exceeding returns RATE_LIMITED with retryAfterSec |
| — | global cap only | All other operations, e.g. cardBalance, cardFundSubClientStatus, multiSendJobStatus, chargeCard, updateCardLimit, createPartnerCustomerOrganization, createCardApplicationForPartnerUser |
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 registers your Treasury wallet as this sub-client's collateral admin (treasury must be configured).
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...) that becomes the collateral admin of the sub-client's card contract — the wallet whose signature authorizes funding and withdrawal of that cardholder's collateral. Supply your customer's own EVM address only if they will self-custody and sign for their own collateral — note that a self-custody sub-client cannot be withdrawn via cardWithdrawForPartner, which always signs with your Treasury. For partner-managed withdrawal, use createWallet: true (below).
If you'd rather control collateral centrally, pass createWallet: true instead — Agio registers your configured Treasury wallet as the collateral admin for this sub-client (see the tip below), so a single wallet you already control administers every sub-client.
createWallet: true — use your Treasury as the collateral admin
Pass createWallet: true in place of walletAddress and Agio registers your configured Treasury wallet as this sub-client's on-chain collateral admin:
- One admin for every sub-client. All sub-clients you provision this way share your Treasury wallet as their collateral admin, so the Treasury funds and withdraws on their behalf — you never manage a separate wallet per cardholder.
- Treasury must be configured. Your partner Treasury is provisioned during onboarding (contact your Agio account manager). If it isn't configured,
createWallet: truereturnsVALIDATION_ERROR. - Re-query. The mutation echoes your Treasury address on
CardApplicationResponse.walletAddress; you can also read it later fromAgioCard_card_application.wallet_address(via Hasura) — the same Treasury address appears on every sub-client provisioned this way. - 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) |
card_transaction.authorized | a card transaction is authorized — the first notification for a spend |
card_transaction.settled | a card transaction reaches its completed/settled state |
card_transaction.declined | a card transaction is declined |
card_transaction.reversed | a card transaction is reversed (refund/chargeback) |
Subscribe with the enum name, not the wire name
The events argument to subscribePartnerWebhook takes the GraphQL enum (SCREAMING_SNAKE); Agio maps it to the dotted wire name you receive in x-agio-event and the body eventName:
| Subscribe with (enum) | You receive (wire name) |
|---|---|
CARD_APPLICATION_STATUS_CHANGED | card_application.status_changed |
CARD_TRANSACTION_AUTHORIZED | card_transaction.authorized |
CARD_TRANSACTION_SETTLED | card_transaction.settled |
CARD_TRANSACTION_DECLINED | card_transaction.declined |
CARD_TRANSACTION_REVERSED | card_transaction.reversed |
Because events is a GraphQL enum, passing a dotted wire name — or any non-enum value — is rejected at schema validation, before the resolver runs: you get a top-level errors[] entry with extensions.code: BAD_USER_INPUT (e.g. Value "card_transaction.authorized" does not exist in "PartnerWebhookEvent" enum type), not the structured errorCode: INSERT_FAILED.
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 delivered via webhook — subscribe to the card_transaction.* events above. Each fires exactly once per state transition (keyed on card_transaction.notification_state), so you receive one POST as a spend is authorized, one when it settles, and one if it is declined or reversed. You can still reconcile by polling AgioCard_card_transaction with the same updated_at cursor pattern used for application-status polling above, filtering transaction_type: { _eq: "spend" } (the table also records non-spend rows such as collateral, payments, and fees, so sum spend on the filtered set). 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", "CARD_TRANSACTION_SETTLED"] } }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, and any hostname that DNS-resolves to a private IP (rebinding defense) — the full reject list (CIDR ranges, metadata hosts, and reason codes) is in the requirements below. 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"
}
}A card_transaction.* event uses the same envelope with a transaction data block:
POST <your-url>
x-agio-event: card_transaction.settled
x-agio-delivery-id: <unique per attempt>
x-agio-timestamp: <unix ms>
x-agio-signature: <hex hmac-sha256(signingSecret, `${timestamp}.${rawBody}`)>
{
"eventName": "card_transaction.settled",
"eventId": "ctx_<deterministic sha256 of (table, pk, updated_at)>",
"deliveredAt": "2026-05-26T10:00:00.123Z",
"data": {
"cardApplicationId": 42,
"cardId": "card_…",
"cardTransactionId": "…",
"externalId": "…",
"transactionType": "spend",
"amount": 12.34,
"currency": "USD",
"status": "completed",
"notificationState": "completed_notified",
"occurredAt": "2026-05-26T09:59:58.000Z"
}
}Which notificationState maps to which event — the notificationState in the data block tells you which card_transaction.* event this delivery is:
notificationState | Event delivered |
|---|---|
created_notified | card_transaction.authorized |
completed_notified | card_transaction.settled |
declined_notified | card_transaction.declined |
reversed_notified | card_transaction.reversed |
A transaction whose notificationState is none of these does not emit a partner event.
The User-Agent is stable — safe to allowlist in WAF rules or filter in receiver logs. Agio aborts the request after 10s total; 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 it from real traffic (cas_…for application events,ctx_…for transaction events).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 = falseand drains pending retry jobs. No notification is sent — webhooks simply stop; detect this by polling your subscription status (is_active/consecutive_failures, documented in the subscription fields above). 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
There are two ways to add spending power to a card. Direct deposit is not a partner-API mutation — you send a supported stablecoin on-chain to the card's deposit address (below). Alternatively, if you hold collateral in a single Treasury wallet, cardFundSubClientFromTreasury moves funds from your Treasury into a sub-client for you. With direct deposit, 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. To move funds out of a smart wallet to many destinations (disbursements, sweeps, bulk payouts), see Multisend.
You operate a single Treasury wallet, which Agio registers as the on-chain collateral admin for every sub-client you provision with createWallet: true. Each sub-client has a collateral contract backing that cardholder's spending; because your Treasury administers that contract, it can move value in and out on the sub-client's behalf — no per-sub-client wallet is involved:
Fund: Treasury wallet ──ERC-20 transfer──▶ sub-client collateral (cardFundSubClientFromTreasury)
Withdraw: sub-client collateral ──────────────▶ Treasury wallet (cardWithdrawForPartner)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 # prod: 8453 (Base) · dev: 11155111 (Eth Sepolia) or 84532 (Base Sepolia)
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 span testnets — Ethereum Sepolia (11155111) and Base Sepolia (84532); on prod, Base mainnet (8453).
Supported stablecoin
Send USDC on prod (Base mainnet). On dev the test collateral token depends on the deposit chain — e.g. rUSD on Base Sepolia (mint it from the rUSD sandbox faucet). Always read deposit_chain_id from the application row, then 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 · varies by chain on dev (e.g. rUSD on 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
}
}Pull it all in one call
AgioCard_vw_card nests the card details, the deposit address (via card_application), and these balances (via balance) — see Client card info in one call to fetch a cardholder's full state in a single query.
Sample fund flow
- Read the application's
deposit_address+deposit_chain_id(above) — created withcreateWallet: true - Send the supported stablecoin (USDC on Base mainnet · the chain's test token on dev, e.g. rUSD on Base Sepolia) 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.
Fund a sub-client from your treasury (cardFundSubClientFromTreasury)
If you hold collateral in a single Treasury wallet instead of depositing to each card's address, cardFundSubClientFromTreasury moves USDC from your Treasury into a sub-client's collateral via an on-chain ERC-20 transfer — Agio signs it for you. The funding chain is taken from your treasury configuration (there is no chainId input).
This call is asynchronous: it enqueues a job and returns a jobId immediately; the on-chain transfer settles afterward.
| Input field | Type | Description |
|---|---|---|
externalUserId | String! | The sub-client's card_user_id to fund. |
amountCents | Int! | Funding amount in integer cents. |
The response reports only synchronous (enqueue-time) failures:
errorCode | When |
|---|---|
INVALID_AMOUNT | amountCents is not a positive integer. |
SUBCLIENT_NOT_FOUND | Unknown sub-client, or not managed by your partner organization. |
TREASURY_NOT_CONFIGURED | No treasury configuration exists for your partner organization. |
mutation {
cardFundSubClientFromTreasury(input: { externalUserId: "9f3c…", amountCents: 50000 }) {
success
jobId
errorCode
}
}{ "data": { "cardFundSubClientFromTreasury": { "success": true, "jobId": "b2e1…", "errorCode": null } } }Poll funding status (cardFundSubClientStatus)
The background transfer settles after a successful enqueue, and it can still fail there (e.g. the sub-client's collateral contract isn't ready, a treasury-wallet mismatch, or an indeterminate send). Poll cardFundSubClientStatus(jobId) with the jobId from the enqueue response to observe the async outcome.
query FundSubClientStatus($jobId: String!) {
cardFundSubClientStatus(jobId: $jobId) {
success
jobId
status
txHash
failedReason
failedCode
attempts
errorCode
errorMessage
}
}{ "jobId": "b2e1…" }Jobs advance pending → running → submitted → completed, branching to failed on rejection:
status | Meaning | Your move |
|---|---|---|
pending | Queued, not yet picked up. | Keep polling. |
running | Worker is resolving the sub-client + treasury config. | Keep polling. |
submitted | Broadcast to the chain; awaiting confirmation. | Keep polling for completed + txHash. |
completed | On-chain and confirmed. txHash is set. | Done. |
failed | Rejected before or at broadcast; see failedReason. | Read failedCode / failedReason, fix, resubmit. |
Async failures surface here as status = failed with a human-readable failedReason and a machine-readable failedCode (e.g. COLLATERAL_CONTRACT_NOT_READY, TREASURY_WALLET_MISMATCH, SUBCLIENT_NOT_FOUND, TREASURY_NOT_CONFIGURED) — branch on failedCode, not on the free-text failedReason. The query-level errorCode (FORBIDDEN, NOT_FOUND, INTERNAL_ERROR) is separate and reports problems reading the job itself.
A job stuck in submitted is indeterminate, not failed
If the on-chain send outcome is unknown (a crash or timeout after broadcast), the job is left in submitted with failedCode = FUNDING_SEND_INDETERMINATE rather than flipped to failed — the transfer may already be on-chain. Do not resubmit a submitted job; that risks a double-send. Reconcile against the sub-client's on-chain collateral history (or ask Agio) to confirm whether it landed.
{
"data": {
"cardFundSubClientStatus": {
"success": true,
"jobId": "b2e1…",
"status": "completed",
"txHash": "0xabc123…",
"failedReason": null,
"failedCode": null,
"attempts": 1
}
}
}Belt-and-suspenders: still confirm the balance
The status query is the primary signal, but an out-of-band balance check remains a useful fallback — re-read the sub-client's balance (cardBalanceByExternalUserId or AgioCard_vw_card_user_balance.collateral_balance) until the collateral increase appears, especially when reconciling a submitted/indeterminate job.
Withdrawing collateral
cardWithdrawForPartner pulls USDC collateral from a sub-client's collateral contract back into your configured Treasury wallet. Because your Treasury is the contract's registered collateral admin, Agio produces the on-chain admin signature with your Treasury — no per-sub-client wallet is touched. Unlike funding, this call is synchronous — the response carries the on-chain result.
Requires the Treasury to be the sub-client's collateral admin
Withdrawal only works for sub-clients whose collateral admin is your Treasury — i.e. those provisioned with createWallet: true (or migrated to Treasury-admin). A sub-client created with a self-custody walletAddress is not withdrawable here, since the Treasury can't sign for a contract it doesn't administer.
The recipient is hard-locked
The destination is always the treasury_wallet_address from your treasury configuration — callers cannot supply a destination address. (An admin-curated recipient allowlist exists in the schema via upsertPartnerWithdrawalAllowlist, but that gate is not currently enforced — the recipient is hard-locked to your Treasury regardless.)
| Input field | Type | Description |
|---|---|---|
externalUserId | String! | The sub-client's card_user_id. |
amountCents | Int! | Withdrawal amount in integer cents. |
chainId | Int | Optional. Defaults to your treasury config chain when omitted; an unsupported value returns INTERNAL_ERROR. |
dryRun | Boolean | When true, validates and prices the withdrawal without submitting on-chain. |
| Response field | Type | Description |
|---|---|---|
success | Boolean! | true when the withdrawal (or dry run) succeeded. |
transactionHash | String | On-chain tx hash on a submitted (non-dry-run) success. |
errorCode | String | One of the codes below, on failure. |
error | String | Human-readable detail. |
spendingPowerCents | Int | Available spending power — on INSUFFICIENT_SPENDING_POWER and on a dry run. |
requestedCents | Int | The requested amount echoed back — on INSUFFICIENT_SPENDING_POWER and a dry run. |
retryAfterSec | Int | Seconds to wait before retrying — on CARD_SIGNING_IN_FLIGHT. |
errorCode | When |
|---|---|
INVALID_AMOUNT | amountCents is not a positive integer. |
SUBCLIENT_NOT_FOUND | externalUserId is unknown, or the sub-client is not managed by your partner organization. |
TREASURY_NOT_CONFIGURED | No treasury configuration exists for your partner organization. |
INSUFFICIENT_SPENDING_POWER | The requested amount exceeds the sub-client's available collateral (spendingPowerCents + requestedCents are populated). |
CARD_SIGNING_IN_FLIGHT | A concurrent signing operation is in flight for this sub-client — retry after retryAfterSec seconds. Match on this exact string. |
INTERNAL_ERROR | Unexpected server error (also returned for an unsupported chainId). Details are logged, not returned. |
Always dry-run first to price a withdrawal and confirm spending power before submitting:
mutation {
cardWithdrawForPartner(input: { externalUserId: "9f3c…", amountCents: 50000, dryRun: true }) {
success
spendingPowerCents
requestedCents
}
}{ "data": { "cardWithdrawForPartner": { "success": true, "spendingPowerCents": 120000, "requestedCents": 50000 } } }Drop dryRun to submit the withdrawal to your Treasury wallet:
mutation {
cardWithdrawForPartner(input: { externalUserId: "9f3c…", amountCents: 50000 }) {
success
transactionHash
errorCode
error
}
}{ "data": { "cardWithdrawForPartner": { "success": true, "transactionHash": "0xabc…" } } }Your smart wallets
When you provision a sub-client with createCardApplicationForPartnerUser(input: { createWallet: true }), Agio registers your Treasury wallet as that sub-client's collateral admin rather than minting a separate per-cardholder wallet. AgioCard_vw_partner_wallet lists the smart wallets scoped to your partner organization (including your Treasury), with the ids and on-chain token balances you need to drive Multisend. Results are automatically scoped to your partner organization.
List your wallets (AgioCard_vw_partner_wallet)
Omit the filter to list every smart wallet your org owns; add where: { id: { _eq: N } } for a single wallet (handy for subsequent lookups):
query OrgWallets {
AgioCard_vw_partner_wallet {
id # pass as sourceWalletId to multiSendFromWallet
wallet_address
chain_id # wallet home/registration chain (1 = Ethereum) — transfers run on the token's chain
chain_name
status # approved / pending / frozen …
wallet_type
total_balance_usd
token_balances {
token_chain_id # pass as tokenChainId on each multiSendFromWallet transfer
token_symbol
token_balance
token_balance_usd
token_address
is_native
decimals
smart_wallet_trading_enabled # both true => eligible to send via multisend
verified
}
}
}Wallet fields:
| Field | Notes |
|---|---|
id | The wallet's Agio id — pass it as sourceWalletId to multiSendFromWallet. |
wallet_address | The on-chain EVM address of the smart wallet. |
chain_id / chain_name | The wallet's home/registration chain (Ethereum mainnet, 1). Transfers run on the token's chain, not this one — see the note below. |
status | Wallet status (approved, pending, frozen, …). |
total_balance_usd | Rolled-up USD value across the wallet's tokens. |
token_balances (nested array):
| Field | Notes |
|---|---|
token_chain_id | The Agio token-chain id — pass it as tokenChainId on a transfer. |
token_symbol / token_name | The asset. |
token_balance / token_balance_usd | On-chain balance held at the wallet, and its USD value. |
token_address / is_native / decimals | Contract address (or native), and token decimals. |
smart_wallet_trading_enabled + verified | Both true ⇒ the token is eligible to send via multisend. A token that fails either is rejected with TOKEN_CHAIN_NOT_ELIGIBLE. |
Wallet home chain vs. transfer chain
chain_id is the smart wallet's home/registration network — Ethereum mainnet (1) today — not the chain your transfers execute on. The smart-wallet address is identical on every EVM chain, so one wallet holds and moves assets across chains. Multisend resolves the execution chain per token from token_balances[].token_chain_id: the eligible collateral token settles on Base (Base Sepolia on dev, Base on prod). Pick the tokenChainId for the chain you want to send on.
This is where multisend ids come from
multiSendFromWallet takes a numeric sourceWalletId and a tokenChainId per transfer — read id and token_balances[].token_chain_id from this query rather than hardcoding them. The token_balances are the same on-chain deposit balances as AgioCard_vw_card_token_balance, with the token-chain id and eligibility flags added.
Multisend
multiSendFromWallet moves funds from one source smart wallet to many destinations in a single atomic on-chain batch — one ERC-4337 UserOp, so every transfer lands together or none do. It's the partner-facing way to fan a treasury wallet out to many recipients: bulk payouts, disbursements, sweeping collateral, or bulk-funding many cards' collateral in one batch.
Unlike the card operations above, multisend is asynchronous. The mutation validates synchronously and enqueues a job — it does not wait for the on-chain broadcast. You get back a jobId; poll multiSendJobStatus until the job reaches a terminal state.
One wallet in, many out — all on one chain
Every transfer in a batch must resolve to the same chain; mixing chains is rejected (MIXED_CHAINS). The source is a smart wallet your organization controls (sourceWalletId); destinations are any EVM addresses.
Funding a card vs. multisend — opposite directions
Funding cards tops up a single card by sending stablecoin to its deposit address. Multisend moves funds out of a smart wallet to many destinations at once — and because those destinations can be card deposit addresses, multisend doubles as a way to bulk-fund many cards from a treasury. Everyday top-up = one direct deposit; multisend = the batch primitive.
Send a batch (multiSendFromWallet)
mutation MultiSend($input: MultiSendFromWalletInput!) {
multiSendFromWallet(input: $input) {
success
jobId
alreadyEnqueued
errorCode
errorMessage
retryAfterSec
}
}{
"input": {
"sourceWalletId": 1234,
"transfers": [
{ "tokenChainId": 42, "destination": "0x1111111111111111111111111111111111111111", "amount": "100.5" },
{ "tokenChainId": 42, "destination": "0x2222222222222222222222222222222222222222", "amount": "250" }
],
"idempotencyKey": "payout-2026-06-18-batch-7"
}
}Input fields:
| Field | Type | Notes |
|---|---|---|
sourceWalletId | Int! | Agio id of the smart wallet to send from — get it from AgioCard_vw_partner_wallet.id. Must belong to your partner organization. |
transfers | [MultiSendTransferInput!]! | 1–20 transfers (see Limits). |
transfers[].tokenChainId | Int! | Agio token-chain id for the asset + chain — read it from a wallet's token_balances[].token_chain_id. The asset must be smart-wallet-trading-enabled and verified. |
transfers[].destination | String! | Recipient EVM address (0x…). The zero address is rejected; the address is checksummed on the way in. |
transfers[].amount | String! | Human-readable amount (e.g. "100.5"). Must be positive and within the token's decimals. |
idempotencyKey | String | Optional, ≤128 chars, no whitespace. Omit to let Agio derive one (see Idempotency). |
A successful response means enqueued, not executed:
{
"data": {
"multiSendFromWallet": { "success": true, "jobId": "payout-2026-06-18-batch-7", "alreadyEnqueued": null, "errorCode": null, "errorMessage": null, "retryAfterSec": null }
}
}success: true means the batch passed synchronous validation (auth, single chain, token eligibility, rate limit) and a job is queued — capture jobId and poll for the on-chain outcome. A success: false carries an errorCode; the job is not queued.
Bulk-fund cards from a treasury wallet
A card is collateralized simply by sending an approved token to its deposit address (Funding cards), and multisend sends to any EVM address — so a single batch aimed at your cards' deposit addresses funds many cards at once, straight from a treasury wallet. Each transfer is a standard on-chain token transfer; the card processor detects the deposit at that address and credits the collateral automatically, with no requirement on who sent it — funding from a treasury wallet works exactly like a manual deposit.
- Read each card's funding address —
AgioCard_card_application.deposit_address+deposit_chain_id. Every card in one batch must be on the same chain (mixed chains are rejected withMIXED_CHAINS), so batch by chain. - Pick the collateral token your treasury holds — a
token_balances[].token_chain_idthat issmart_wallet_trading_enabled && verified(see Your smart wallets) and accepted by the cards' card contract. An ineligible or unaccepted token lands on-chain but won't credit. - Send the batch from your treasury (
sourceWalletId), one transfer per card:
{
"input": {
"sourceWalletId": 1234,
"transfers": [
{ "tokenChainId": 42, "destination": "<card A deposit_address>", "amount": "100" },
{ "tokenChainId": 42, "destination": "<card B deposit_address>", "amount": "250" }
],
"idempotencyKey": "fund-cards-2026-06-19-batch-1"
}
}Once the job reaches completed, confirm each card's new collateral via AgioCard_vw_card_token_balance (on-chain) and AgioCard_vw_card_user_balance (credit/spending). Up to 20 cards per batch (Limits) — split larger card sets across batches.
Same wallet, both directions
A card's deposit_address (created with createWallet: true) is itself an org smart wallet you own — so one wallet can be a multisend source (sweeping funds out) and another wallet's multisend destination (funding a card in). Multisend doesn't distinguish "funding" from "payout"; it just moves an approved token to an address.
Idempotency & safe retries
Multisend is safe to retry — submitting the same batch twice never double-sends.
- Provide your own
idempotencyKey(recommended): any opaque string ≤128 chars with no whitespace. Reuse it verbatim when retrying a timed-out request. - Or omit it: Agio derives a deterministic key from
(your organization, sourceWalletId, the normalized set of transfers). The same batch collapses to the same job even without a key — and because amounts are normalized to base units first,"100.5"and"100.50"are treated as identical.
On a duplicate submission you get alreadyEnqueued: true with the existing job's jobId — poll that id rather than treating it as a fresh send:
{ "data": { "multiSendFromWallet": { "success": true, "jobId": "payout-2026-06-18-batch-7", "alreadyEnqueued": true } } }Retry with the same key — never re-key
On a timeout, retry with the same idempotencyKey. You'll either enqueue the job (first time the server saw it) or get alreadyEnqueued: true pointing at the in-flight job — both safe. Generating a fresh key on retry is the one way to double-send.
Poll job status (multiSendJobStatus)
query MultiSendStatus($jobId: String!) {
multiSendJobStatus(jobId: $jobId) {
success
jobId
status
txHash
failedReason
attempts
errorCode
errorMessage
}
}{ "jobId": "payout-2026-06-18-batch-7" }Jobs advance pending → running → submitted → completed, branching to failed or insufficient_balance on rejection:
status | Meaning | Your move |
|---|---|---|
pending | Queued, not yet picked up. | Keep polling. |
running | Worker is validating + checking balances. | Keep polling. |
submitted | Broadcast to the chain; awaiting confirmation. | Keep polling for completed + txHash. |
completed | On-chain and confirmed. txHash is set. | Done. |
failed | Rejected before or at broadcast; see failedReason. | Read failedReason, fix, resubmit as a new batch. |
insufficient_balance | The source wallet didn't hold enough for the batch. | Top up the wallet, then resubmit. |
A job stuck in submitted is indeterminate, not failed
If the broadcast outcome is unknown (e.g. a confirmation timeout), the job is left in submitted rather than flipped to failed — the UserOp may already be on-chain. Do not resubmit a submitted job; that risks a double-send. Reconcile against the wallet's on-chain history (or ask Agio) to confirm whether it landed.
Terminal-state examples:
{ "data": { "multiSendJobStatus": { "success": true, "jobId": "payout-2026-06-18-batch-7", "status": "completed", "txHash": "0xabc123…", "failedReason": null, "attempts": 1 } } }{
"data": {
"multiSendJobStatus": {
"success": true,
"jobId": "payout-2026-06-18-batch-7",
"status": "failed",
"txHash": null,
"failedReason": "batch USD 750000.00 exceeds cap 500000",
"attempts": 1
}
}
}{
"data": {
"multiSendJobStatus": {
"success": true,
"jobId": "payout-2026-06-18-batch-7",
"status": "insufficient_balance",
"txHash": null,
"failedReason": "USDC: need 350500000, have 100000000 (wei)",
"attempts": 1
}
}
}failedReason is a human-readable diagnostic, not a stable code — surface it for debugging, don't branch on its exact text. attempts is how many times the worker has run the job (BullMQ retries transient errors automatically).
Polling guidance: poll every few seconds; most batches reach a terminal state within a few worker cycles.
Limits & controls
| Limit | Default | Notes |
|---|---|---|
| Destinations per batch | 20 | transfers array length (minimum 1). |
| Max batch value | $500,000 | Summed USD across the batch. Fails closed — if any token's USD price is unavailable, the job is rejected, not skipped. |
| Send rate | 5 / 10 min | Per partner organization. Exceeding returns RATE_LIMITED with retryAfterSec. |
| Request body size | 512 KB | The partner endpoint caps the multisend request body. |
These are server-side defaults, tunable per environment — treat the numbers as the current production configuration, not a hard contract.
Beyond the size limits, every batch is checked for single chain (all tokenChainIds resolve to one chain), token eligibility (each asset is smart-wallet-trading-enabled and verified), and sufficient balance (pre-broadcast, against live on-chain balances).
Error codes
multiSendFromWallet returns these synchronously in errorCode — when one is present the job is not enqueued.
errorCode | Operation | Cause |
|---|---|---|
FORBIDDEN | both | Caller is not a partner organization. On multiSendFromWallet, also: sourceWalletId not found or not owned by your org (generic — does not reveal existence). |
VALIDATION_ERROR | multiSendFromWallet | Input failed validation — bad address, non-positive amount, amount exceeding the token's decimals, empty or over-20 transfers, or an unknown tokenChainId. |
MIXED_CHAINS | multiSendFromWallet | The transfers resolve to more than one chain. Split into one batch per chain. |
TOKEN_CHAIN_NOT_ELIGIBLE | multiSendFromWallet | A tokenChainId is not enabled for smart-wallet trading (or not verified). |
RATE_LIMITED | multiSendFromWallet | Org send-rate exceeded. Back off for retryAfterSec, then retry. |
NOT_FOUND | multiSendJobStatus | No job with that jobId under your organization (also returned for another org's job — existence-hidden). |
INTERNAL_ERROR | both | Unexpected server-side error. Re-poll by jobId before resubmitting a send. |
Failures after a job is enqueued don't appear here — they surface as multiSendJobStatus.status = failed (or insufficient_balance) with a human-readable failedReason. See the status lifecycle above.
Full type definitions are in the Partner API Reference.
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
}
}Client card info in one call
AgioCard_vw_card carries relationships, so one nested query returns everything you'd show a cardholder — card details, the collateral / deposit address (via card_application), and credit / spending (via balance) — no client-side joins:
query ClientCardInfo($cardUserId: uuid!) {
AgioCard_vw_card(where: { card_user_id: { _eq: $cardUserId } }) {
last4
status
type
expiration_month
expiration_year
limit_amount
limit_frequency
card_application {
deposit_address # where the client funds the card
deposit_chain_id # on which chain
application_status
}
balance {
credit_limit
collateral_balance
spending_power
balance_due
}
}
}AgioCard_card_user exposes the same from the cardholder side — it nests balance, card_applications, cards, and transactions. For the live on-chain token balance at the deposit address (vs. the rolled-up collateral_balance), read AgioCard_vw_card_token_balance keyed by deposit_address.
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 route to. Only Sumsub is currently supported |
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 |
Multisend codes (multiSendFromWallet / multiSendJobStatus) are listed in the Multisend error codes section above.
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).
Changelog
- Breaking: the
cardWithdrawForPartnerin-flight-signing error code was renamed toCARD_SIGNING_IN_FLIGHT. If you previously matched the legacy error-code string, update it accordingly.