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 |
|---|---|---|
|
No |
Payment created, awaiting customer action on checkout page |
|
Yes |
Payment confirmed on blockchain - fulfill the order |
|
Yes |
Payment failed — inspect the payment object's |
|
Yes |
The prompt's |
|
Yes |
Payment was cancelled by customer or system |
The payment object also carries a sessionStatus describing the checkout session itself:
Session status |
Description |
|---|---|
|
The checkout session is open. |
|
The customer completed checkout. |
|
The session expired. |
|
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
Prefer webhooks: Use Webhooks as the primary signal and polling for fallback/reconciliation — don't poll synchronously inside HTTP handlers.
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.Handle all terminal states: Your code should handle
successful,unsuccessful,expired, andcancelled. Forunsuccessful, log the payment'slastError.Implement idempotency: Ensure processing the same payment twice doesn't cause issues (e.g., double fulfillment). On the create side, use the
Idempotency-Keyheader — see External Payments API.Log everything: Store the full response for debugging and audit trails.
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() }
);
}