Appearance
Receive webhooks
Subscribe a public HTTPS receiver to application status changes and card transactions. A subscription covers your partner organization's managed cardholders; you do not need one subscription per customer or cardholder.
Webhook deliveries use a per-subscription signing secret. This is separate from the client_secret used to sign your API requests.
Subscribe
graphql
mutation Subscribe($input: SubscribePartnerWebhookInput!) {
subscribePartnerWebhook(input: $input) {
success
subscriptionId
signingSecret
errorCode
errorMessage
}
}json
{
"input": {
"url": "https://partner.example/agio-webhooks",
"events": ["CARD_APPLICATION_STATUS_CHANGED", "CARD_TRANSACTION_AUTHORIZED", "CARD_TRANSACTION_SETTLED", "CARD_TRANSACTION_DECLINED", "CARD_TRANSACTION_REVERSED"]
}
}Store subscriptionId and signingSecret after success: true. The secret is returned once and cannot be read back. A single subscription can include every event you need.
Only one active subscription is allowed for the same partner and canonical URL. Agio normalizes the scheme and host, removes fragments and trailing path slashes, and preserves path and query case. A duplicate returns DUPLICATE_ACTIVE_SUBSCRIPTION. To replace its event set, unsubscribe and create a new subscription.
Subscribed events
| Event name | notificationState | Meaning |
|---|---|---|
card_application.status_changed | n/a | Application status changed |
card_transaction.authorized | created_notified | Spend authorized; amount is pending and held |
card_transaction.settled | completed_notified | Transaction completed |
card_transaction.declined | declined_notified | Spend declined |
card_transaction.reversed | reversed_notified | Transaction reversed |
Subscribe using GraphQL enum values; received events use dotted wire names:
| GraphQL enum | Received eventName |
|---|---|
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 |
A dotted name in the subscription's events input fails GraphQL validation. subscription.created is a test event emitted by the server and cannot be explicitly subscribed to.
Verify each delivery
Deliveries are JSON POSTs with these headers:
| Header | Value |
|---|---|
x-agio-event | Dotted event name |
x-agio-delivery-id | Attempt identifier; changes on retry |
x-agio-timestamp | Unix time in milliseconds |
x-agio-signature | Hex HMAC-SHA256 of ${timestamp}.${rawBody} using your webhook signing secret |
Preserve the raw request bytes before your framework's JSON parser changes them. Verify the signature in constant time, then parse and persist the event. This verifier accepts an array of secrets so it can handle a rotation overlap:
typescript
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody: Uint8Array, timestamp: string | undefined, signature: string | undefined, signingSecrets: string[]) {
if (!timestamp || !/^\d+$/.test(timestamp) || !signature || !/^[a-fA-F0-9]{64}$/.test(signature)) return false;
const time = Number(timestamp);
if (!Number.isFinite(time) || Math.abs(Date.now() - time) > 5 * 60_000) return false;
const received = Buffer.from(signature, "hex");
return signingSecrets.some((secret) => {
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return timingSafeEqual(received, expected);
});
}Return a 2xx response after signature verification and durable event acceptance, then do business processing asynchronously. Each attempt times out after 30 seconds. Redirects are treated as failures.
Your receiver must be publicly routable over HTTPS. Localhost, private or loopback IPs, cloud metadata hosts, and hostnames resolving to private IPs are rejected before delivery. Use a deployed endpoint or a public HTTPS tunnel for development. The starter kit includes an Express receiver.
Application events
json
{
"eventName": "card_application.status_changed",
"eventId": "cas_<stable event hash>",
"deliveredAt": "2026-10-04T06:00:00.123Z",
"data": {
"cardApplicationId": 42,
"cardApplicationExternalId": "<issuer application UUID>",
"cardUserId": "<cardUserId>",
"oldStatus": "PENDING",
"newStatus": "APPROVED",
"nextStep": null,
"reasonDescription": null,
"completionUrl": null
}
}Use cardApplicationId to join the event to your stored application. nextStep is RESUBMIT, AWAIT_REVIEW, TERMINAL, or null. It is null for APPROVED and ACTIVE.
completionUrl is a composed signed verification link when the application is NEEDSINFORMATION or NEEDSVERIFICATION. Open it as returned. It is null for approval, denial, and manual review. reasonDescription may be null, including when a moderation comment has not reached the record at delivery time. Act on the status and nextStep; use application queries to reconcile current state.
Transaction events
All transaction events use the same envelope. This is an abbreviated settled-spend example:
json
{
"eventName": "card_transaction.settled",
"eventId": "ctx_<stable event hash>",
"deliveredAt": "2026-10-04T06:00:00.123Z",
"data": {
"cardTransactionId": 5396,
"cardTransactionExternalId": "<issuer transaction UUID>",
"cardId": 86,
"cardExternalId": "<issuer card UUID>",
"cardApplicationId": 42,
"cardUserId": "<cardUserId>",
"cardCompanyId": "<issuer company UUID>",
"eventType": "transaction.completed",
"transactionType": "spend",
"status": "completed",
"notificationState": "completed_notified",
"amount": 9500,
"currency": "USD",
"localAmount": 8700,
"localCurrency": "EUR",
"merchantName": "Example Merchant",
"postedAt": "2026-10-04T05:59:00.000Z",
"updatedAt": "2026-10-04T05:59:01.000Z"
}
}Spend amounts are integer minor units: amount: 9500 with currency: "USD" means $95. localAmount and localCurrency describe the original merchant currency. A transaction keeps its cardTransactionExternalId through authorization, settlement, and reversal. Events can arrive out of order; reconcile against current transaction state rather than assuming delivery order is the lifecycle order.
Authorized spend has status: "pending" and already reduces available spending power. Show pending transactions alongside settled purchases. A declined attempt does not hold funds. A reversal can follow settlement.
Field availability
The payload includes the following fields when source data is available. Handle null values, including for identifiers or timestamps that have not propagated.
| Group | Fields |
|---|---|
| Record and ownership | cardTransactionId, cardTransactionExternalId, cardApplicationId, cardUserId, cardCompanyId |
| Lifecycle | eventType, transactionType, status, notificationState, createdAt, updatedAt |
| Card | cardId, cardExternalId, cardType |
| Amounts | amount, currency, localAmount, localCurrency, authorizedAmount, authorizationUpdateAmount |
| Authorization | authorizationMethod, authorizedAt, declinedReason |
| Merchant | merchantName, merchantCity, merchantCountry, merchantCategory, merchantCategoryCode, enrichedMerchantName, enrichedMerchantIcon, enrichedMerchantCategory |
| Collateral transfer | chainId, walletAddress, transactionHash |
| Other transfer details | transferId, transferSourceRail, transferDestinationRail, feeDescription, postedAt |
authorizationMethod is the POS entry-mode code, not a card-network approval code. eventType is the ingested issuer or Agio event category; it is different from the partner webhook's eventName.
Collateral events
transactionType: "collateral" describes funds entering or leaving the cardholder's shared collateral balance. Its signed amount indicates direction: positive for deposits, negative for withdrawals. walletAddress, chainId, and transactionHash describe the on-chain movement when available.
Collateral is shared across the cardholder's cards, so cardId and cardExternalId are null by design. Merchant and authorization fields are also inapplicable. Reconcile collateral by cardUserId and the application or company identity, rather than by an individual card.
For spend, postedAt generally appears after settlement; for collateral, it records posting. Do not apply a universal "null until a spend settles" rule to every transaction type. Payment and fee rows are readable in AgioCard_card_transaction but do not emit the subscribed transaction events above.
Test delivery and retries
After subscription creation, Agio asynchronously queues a signed subscription.created event with a test_ event ID and data: { test: true, message: "..." }. Treat it as a reachability and signature check. If it is missing, inspect the subscription and delivery history as well as endpoint reachability.
Deduplicate by the body eventId, which remains stable across retries and manual resends. Do not deduplicate by x-agio-delivery-id or deliveredAt: both identify an attempt and can change. Application event IDs begin with cas_; transaction IDs begin with ctx_.
Non-2xx responses, network failures, and timeouts are retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 12 hours, and 24 hours, for up to eight attempts. The final failure is dead-lettered. Unsafe receiver URLs can be rejected without an HTTP attempt.
A successful delivery resets the subscription's consecutive-failure count. At 30 consecutive failures the subscription becomes inactive and queued retry jobs are drained. Monitor is_active and failure fields; an active-only query hides this failure. Fix the receiver and create a new subscription to resume delivery.
Monitor and resend deliveries
graphql
query Subscriptions {
AgioPlatform_partner_webhook_subscription(order_by: { created_at: desc }) {
id
url
events
is_active
consecutive_failures
last_delivery_at
last_failure_at
last_failure_reason
}
}Signing secrets are not exposed in read queries. Delivery history is scoped to your subscriptions:
graphql
query DeliveryHistory($subscriptionId: uuid!) {
AgioPlatform_partner_webhook_delivery(where: { subscription_id: { _eq: $subscriptionId } }, order_by: { created_at: desc }, limit: 20) {
id
event_name
event_id
http_status
attempt_number
delivered_at
failed_at
dead_lettered
}
}json
{ "subscriptionId": "<subscription UUID>" }To resend a past delivery, use its history-row id:
graphql
mutation Resend($deliveryId: ID!) {
resendPartnerWebhookDelivery(deliveryId: $deliveryId) {
success
newDeliveryId
errorCode
errorMessage
}
}json
{ "deliveryId": "<delivery history id>" }The original eventId and payload are reused. newDeliveryId identifies the queued attempt, not a delivery-history row. An inactive original subscription returns SUBSCRIPTION_INACTIVE; creating another subscription does not reactivate that old subscription or move its deliveries to the new one.
Rotate the signing secret
graphql
mutation Rotate($subscriptionId: ID!) {
rotatePartnerWebhookSecret(subscriptionId: $subscriptionId) {
success
signingSecret
previousSecretValidUntil
errorCode
errorMessage
}
}json
{ "subscriptionId": "<subscription UUID>" }Store the new secret immediately. Agio signs new deliveries with it from the cutover. Keep accepting the previous secret until previousSecretValidUntil (24 hours) for in-flight requests, then remove it from your verifier. ROTATION_RACE means another rotation won; inspect the subscription and coordinate which secret your receiver should use before retrying.
Unsubscribe
graphql
mutation Unsubscribe($subscriptionId: ID!) {
unsubscribePartnerWebhook(subscriptionId: $subscriptionId) {
success
errorCode
errorMessage
}
}json
{ "subscriptionId": "<subscription UUID>" }unsubscribePartnerWebhook sets is_active to false. The subscription stays queryable with is_active: false. Queued and delayed deliveries are drained; an already-running request can still finish. NOT_FOUND also covers a subscription outside your organization.