Skip to main content
Understanding the transaction lifecycle helps you build robust payment flows, handle edge cases, and provide accurate status updates to your customers.

Transaction States

Transactions progress through several states from creation to completion:
PENDING → PROCESSING → COMPLETED

                  FAILED

Status Descriptions

StatusDescriptionNext StateActions Available
PENDINGTransaction created, awaiting processingPROCESSING, FAILEDMonitor status
PROCESSINGPayment being routed and settledCOMPLETED, FAILEDMonitor status
COMPLETEDSuccessfully delivered to recipientTerminalNone (final state)
FAILEDTransaction failed, funds refunded if applicableTerminalCreate new transaction
REJECTEDRejected by recipient or complianceTerminalNone (final state)
REFUNDEDCompleted transaction later refundedTerminalNone (final state)
EXPIREDQuote or payment expired before executionTerminalCreate new quote
Terminal statuses: COMPLETED, REJECTED, FAILED, REFUNDED, EXPIREDOnce a transaction reaches a terminal status, it will not change further.

Outgoing Transaction Flow

Your customer/platform sends funds to an external recipient.

Step-by-Step

1

Create Quote

Lock in exchange rate and fees:
POST /quotes

{
  "source": {"accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"},s
  "destination": {"accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "currency": "EUR"},
  "lockedCurrencySide": "SENDING",
  "lockedCurrencyAmount": 100000
}
Response:
  • Quote ID
  • Locked exchange rate
  • Expiration time (1-5 minutes)
2

Execute Quote

Initiate the payment:
POST /quotes/{quoteId}/execute
Result:
  • Transaction created with status PENDING
  • Source account debited immediately
  • OUTGOING_PAYMENT webhook sent
3

Processing

Grid handles:
  • Currency conversion (if applicable)
  • Routing to appropriate payment rail
  • Settlement with destination bank/wallet
Status: PROCESSINGWebhook: OUTGOING_PAYMENT with updated status
4

Completion or Failure

Success Path:
  • Funds delivered to recipient
  • Status: COMPLETED
  • settledAt timestamp populated
  • Final OUTGOING_PAYMENT webhook sent
Failure Path:
  • Delivery failed (invalid account, etc.)
  • Status: FAILED
  • failureReason populated
  • Funds automatically refunded to source account
  • Final OUTGOING_PAYMENT webhook sent
Most transactions on Grid are completed in seconds.

Webhook Payloads

On Creation (PENDING):
{
  "type": "OUTGOING_PAYMENT",
  "transaction": {
    "id": "Transaction:...",
    "status": "PENDING",
    "type": "OUTGOING",
    "sentAmount": {"amount": 100000, "currency": {"code": "USD"}},
    "receivedAmount": {"amount": 92000, "currency": {"code": "EUR"}},
    "createdAt": "2025-10-03T15:00:00Z"
  }
}
On Completion:
{
  "type": "OUTGOING_PAYMENT",
  "transaction": {
    "id": "Transaction:...",
    "status": "COMPLETED",
    "settledAt": "2025-10-03T15:05:00Z"
  }
}
On Failure:
{
  "type": "OUTGOING_PAYMENT",
  "transaction": {
    "id": "Transaction:...",
    "status": "FAILED",
    "failureReason": "INVALID_BANK_ACCOUNT"
  }
}

Same-Currency Transfers

For same-currency transfers without quotes:

Transfer-Out (Internal → External)

POST /transfer-out

{
  "source": {"accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"},
  "destination": {"accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "currency": "USD"},
  "amount": 100000
}
Response:
{
  "id": "Transaction:...",
  "status": "PENDING",
  "type": "OUTGOING"
}
Follows same lifecycle as quote-based outgoing transactions.

Transfer-In (External → Internal)

POST /transfer-in

{
  "source": {"accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "currency": "USD"},
  "destination": {"accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"},
  "amount": 100000
}
Only works for “pullable” external accounts (Plaid-linked, debit cards, etc.).

Monitoring Transactions

Subscribe to transaction webhooks for real-time updates:
app.post('/webhooks/grid', async (req, res) => {
  const {transaction, type} = req.body;

  if (type === 'OUTGOING_PAYMENT') {
    await updateTransactionStatus(transaction.id, transaction.status);

    if (transaction.status === 'COMPLETED') {
      await notifyCustomer(transaction.customerId, 'Payment delivered!');
    } else if (transaction.status === 'FAILED') {
      await notifyCustomer(transaction.customerId, `Payment failed: ${transaction.failureReason}`);
    }
  }

  res.status(200).json({received: true});
});

Via Polling (Backup)

Query transaction status periodically:
GET /transactions/{transactionId}
Response:
{
  "id": "Transaction:...",
  "status": "PROCESSING",
  "createdAt": "2025-10-03T15:00:00Z",
  "updatedAt": "2025-10-03T15:02:00Z"
}
Poll every 5-10 seconds until terminal status reached.

Listing Transactions

Query all transactions for a customer or date range:
GET /transactions?customerId=Customer:abc123&startDate=2025-10-01T00:00:00Z&limit=50
Response:
{
  "data": [
    {
      "id": "Transaction:...",
      "status": "COMPLETED",
      "type": "OUTGOING",
      "sentAmount": {"amount": 100000, "currency": {"code": "USD"}},
      "receivedAmount": {"amount": 92000, "currency": {"code": "EUR"}},
      "settledAt": "2025-10-03T15:05:00Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
Use for reconciliation and reporting.

Failure Handling

Common Failure Reasons

Failure ReasonDescriptionRecovery
QUOTE_EXPIREDQuote expired before executionCreate new quote
QUOTE_EXECUTION_FAILEDError executing the quoteCreate new quote
INSUFFICIENT_BALANCESource account lacks fundsFund account, retry
TIMEOUTTransaction timed outRetry or contact support

Best Practices

Don’t rely solely on polling:
// ✅ Good: Webhook-driven updates
app.post('/webhooks/grid', async (req, res) => {
  await handleTransactionUpdate(req.body.transaction);
  res.status(200).send();
});

// ❌ Bad: Constant polling
setInterval(() => getTransaction(txId), 5000);
Save transaction IDs to your database:
const transaction = await executeQuote(quoteId);
await db.transactions.insert({
  gridTransactionId: transaction.id,
  internalPaymentId: paymentId,
  status: transaction.status,
  createdAt: new Date()
});
Use idempotency keys for safe retries:
const idempotencyKey = `payment-${userId}-${Date.now()}`;
await createQuote({...params, idempotencyKey});
Translate technical statuses to user-friendly messages:
function getUserMessage(status, failureReason) {
  if (status === 'PENDING') return 'Payment processing...';
  if (status === 'PROCESSING') return 'Payment in progress...';
  if (status === 'COMPLETED') return 'Payment delivered!';
  if (status === 'FAILED') {
    if (failureReason === 'INSUFFICIENT_BALANCE') {
      return 'Insufficient funds. Please add money and try again.';
    }
    return 'Payment failed. Please try again or contact support.';
  }
}
I