Skip to content

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 namenotificationStateMeaning
card_application.status_changedn/aApplication status changed
card_transaction.authorizedcreated_notifiedSpend authorized; amount is pending and held
card_transaction.settledcompleted_notifiedTransaction completed
card_transaction.declineddeclined_notifiedSpend declined
card_transaction.reversedreversed_notifiedTransaction reversed

Subscribe using GraphQL enum values; received events use dotted wire names:

GraphQL enumReceived eventName
CARD_APPLICATION_STATUS_CHANGEDcard_application.status_changed
CARD_TRANSACTION_AUTHORIZEDcard_transaction.authorized
CARD_TRANSACTION_SETTLEDcard_transaction.settled
CARD_TRANSACTION_DECLINEDcard_transaction.declined
CARD_TRANSACTION_REVERSEDcard_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:

HeaderValue
x-agio-eventDotted event name
x-agio-delivery-idAttempt identifier; changes on retry
x-agio-timestampUnix time in milliseconds
x-agio-signatureHex 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.

GroupFields
Record and ownershipcardTransactionId, cardTransactionExternalId, cardApplicationId, cardUserId, cardCompanyId
LifecycleeventType, transactionType, status, notificationState, createdAt, updatedAt
CardcardId, cardExternalId, cardType
Amountsamount, currency, localAmount, localCurrency, authorizedAmount, authorizationUpdateAmount
AuthorizationauthorizationMethod, authorizedAt, declinedReason
MerchantmerchantName, merchantCity, merchantCountry, merchantCategory, merchantCategoryCode, enrichedMerchantName, enrichedMerchantIcon, enrichedMerchantCategory
Collateral transferchainId, walletAddress, transactionHash
Other transfer detailstransferId, 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.

Next: Handle errors and uncertain outcomes.