Checking Payment Status

After redirecting a customer to checkout, you need to monitor the payment status to update your order accordingly.

Webhooks vs Polling

There are two ways to learn about a payment's outcome:

  • Webhooks (recommended) — MiraclePay pushes signed events (payment.succeeded, payment.expired, ...) to your backend the moment they happen. See Webhooks.

  • Polling — your backend periodically calls GET /external/v1/payments/:id.

Use webhooks as the primary signal and polling as a fallback or for reconciliation. Either way, fulfill orders only from a state you read from the API — never from redirect query parameters.

Payment Status Values

Status

Terminal

Description

pending

No

Payment created, awaiting customer action on checkout page

successful

Yes

Payment confirmed on blockchain - fulfill the order

unsuccessful

Yes

Payment failed — inspect the payment object's lastError for the reason

expired

Yes

The prompt's expiresAt passed without payment

cancelled

Yes

Payment was cancelled by customer or system

The payment object also carries a sessionStatus describing the checkout session itself:

Session status

Description

pending

The checkout session is open.

completed

The customer completed checkout.

expired

The session expired.

cancelled

The session was cancelled.

Polling for Status

Endpoint: GET /external/v1/payments/:id (scope payment_prompts:read, signed — see Authentication)

The response is the full payment object:

{
  "object": "payment_prompt",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "amount": 1099,
  "commissionAmount": 22,
  "commissionPayer": "merchant",
  "settlementModel": "split",
  "status": "successful",
  "sessionStatus": "completed",
  "blockchainId": "eth-usdc",
  "tokenAmount": "10.99",
  "transactionHash": "0xabc...",
  "paidAt": "2026-07-21T14:35:00.000Z",
  "expiresAt": "2026-07-21T15:00:00.000Z",
  "createdAt": "2026-07-21T14:30:00.000Z",
  "redirectUrl": "https://shop.example.com/order/12345/complete",
  "customerReferenceId": "order_12345",
  "cancellationReason": null,
  "lastError": null
}

Polling Implementation

Node.js / TypeScript

Using the miraclePayFetch signed-fetch helper from Authentication:

type PaymentStatusValue =
  | "pending"
  | "successful"
  | "unsuccessful"
  | "expired"
  | "cancelled";

interface Payment {
  object: "payment_prompt";
  id: string;
  amount: number; // cents
  status: PaymentStatusValue;
  sessionStatus: "pending" | "cancelled" | "expired" | "completed" | null;
  blockchainId: string | null;
  tokenAmount: string | null;
  transactionHash: string | null;
  paidAt: string | null;
  expiresAt: string;
  createdAt: string;
  customerReferenceId: string | null;
  lastError: { code: string; message: string; occurredAt: string } | null;
}

async function getPayment(promptId: string): Promise<Payment> {
  const response = await miraclePayFetch(
    "GET",
    `/external/v1/payments/${promptId}`,
  );
  if (!response.ok) {
    const { error } = await response.json();
    throw new Error(`MiraclePay ${error.errorCode}: ${error.message}`);
  }
  return response.json();
}

async function waitForPayment(
  promptId: string,
  timeoutMs = 300000, // 5 minutes
): Promise<Payment> {
  const startTime = Date.now();
  const pollInterval = 5000; // 5 seconds

  while (Date.now() - startTime < timeoutMs) {
    const payment = await getPayment(promptId);

    // Return immediately for terminal states
    if (payment.status !== "pending") {
      return payment;
    }

    await new Promise((resolve) => setTimeout(resolve, pollInterval));
  }

  throw new Error("Payment polling timeout");
}

// Usage in your order flow
async function processOrder(orderId: string, promptId: string) {
  try {
    const payment = await waitForPayment(promptId);

    if (payment.status === "successful") {
      await Order.update(orderId, {
        status: "paid",
        paidAt: payment.paidAt,
        blockchainId: payment.blockchainId,
        transactionHash: payment.transactionHash,
      });
      return { success: true, message: "Payment confirmed!" };
    } else {
      await Order.update(orderId, {
        status: "payment_failed",
        failureReason: payment.lastError?.code ?? payment.status,
      });
      return { success: false, message: `Payment ${payment.status}` };
    }
  } catch (error) {
    // Handle timeout - payment may still complete
    return { success: false, message: "Payment pending - check back later" };
  }
}

Python

The signing scheme is language-agnostic — the same canonical string (see Authentication) with hmac and hashlib. Assuming a get_payment(prompt_id) helper that performs the signed GET:

import time

def wait_for_payment(
    prompt_id: str,
    timeout_seconds: int = 300,
    poll_interval: int = 5
) -> dict:
    """Poll until payment reaches a terminal state."""
    start_time = time.time()

    while time.time() - start_time < timeout_seconds:
        payment = get_payment(prompt_id)

        if payment['status'] != 'pending':
            return payment

        time.sleep(poll_interval)

    raise TimeoutError('Payment polling timeout')


def process_order(order_id: str, prompt_id: str) -> dict:
    """Process order after customer completes checkout."""
    try:
        payment = wait_for_payment(prompt_id)

        if payment['status'] == 'successful':
            Order.objects.filter(id=order_id).update(
                status='paid',
                paid_at=payment['paidAt'],
                blockchain_id=payment['blockchainId']
            )
            return {'success': True, 'message': 'Payment confirmed!'}
        else:
            Order.objects.filter(id=order_id).update(
                status='payment_failed',
                failure_reason=payment['status']
            )
            return {'success': False, 'message': f"Payment {payment['status']}"}

    except TimeoutError:
        return {'success': False, 'message': 'Payment pending'}

Background Job Pattern

For production systems, use a background job instead of blocking requests:

1. Create payment and store prompt ID:

// When customer initiates checkout
const { prompt } = await createPayment(amountCents, ["eth-usdc"]);
await Order.update(orderId, {
  paymentPromptId: prompt.id,
  status: 'awaiting_payment'
});

// Schedule background job
await jobQueue.add('check-payment', {
  orderId,
  promptId: prompt.id
});

2. Background job polls for status:

// Background worker
jobQueue.process('check-payment', async (job) => {
  const { orderId, promptId } = job.data;
  const payment = await getPayment(promptId);

  if (payment.status === 'pending') {
    // Re-queue job to check again in 30 seconds
    throw new Error('Still pending - retry');
  }

  // Update order based on final status
  await Order.update(orderId, {
    status: payment.status === 'successful' ? 'paid' : 'payment_failed',
    paymentStatus: payment.status,
  });

  // Send notification to customer
  if (payment.status === 'successful') {
    await sendOrderConfirmation(orderId);
  }
});

Status Transition Diagram

                           ┌─────────────────┐
                           │ Payment Created │
                           └────────┬────────┘
                                    │
                                    ▼
                           ┌─────────────────┐
                           │     pending     │
                           └────────┬────────┘
                                    │
        ┌───────────────┬───────────┼───────────┬───────────────┐
        │               │           │           │               │
        ▼               ▼           ▼           ▼               ▼
┌───────────────┐ ┌────────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐
│  successful   │ │unsuccessful│ │ expired │ │ cancelled │ │  (stays   │
│  (confirmed)  │ │  (failed)  │ │(timeout)│ │  (user)   │ │  pending) │
└───────────────┘ └────────────┘ └─────────┘ └───────────┘ └───────────┘

Best Practices

  1. Prefer webhooks: Use Webhooks as the primary signal and polling for fallback/reconciliation — don't poll synchronously inside HTTP handlers.

  2. Set reasonable timeouts: Poll until the prompt's expiresAt (plus a small buffer) rather than a fixed wall-clock duration — expiry is configured per network.

  3. Handle all terminal states: Your code should handle successful, unsuccessful, expired, and cancelled. For unsuccessful, log the payment's lastError.

  4. Implement idempotency: Ensure processing the same payment twice doesn't cause issues (e.g., double fulfillment). On the create side, use the Idempotency-Key header — see External Payments API.

  5. Log everything: Store the full response for debugging and audit trails.

  6. Graceful degradation: If polling times out, don't assume failure - the payment may still complete.

// Example: Idempotent order update
async function markOrderPaid(orderId, promptId) {
  const order = await Order.findById(orderId);

  // Already processed - skip
  if (order.status === 'paid') {
    return order;
  }

  // Verify payment status
  const payment = await getPayment(promptId);
  if (payment.status !== 'successful') {
    throw new Error(`Payment not successful: ${payment.status}`);
  }

  // Update order atomically
  return Order.findOneAndUpdate(
    { _id: orderId, status: { $ne: 'paid' } },
    { status: 'paid', paidAt: new Date() }
  );
}